diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f076633..0b47fff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,8 +44,14 @@ jobs: with: python-version: "3.12" - - name: Compile check (all modules) - run: python -m compileall -q src/skillcheck + - name: Install dev dependencies + run: pip install -e ".[dev]" + + - name: Ruff check + run: ruff check src tests + + - name: Mypy strict + run: mypy src/skillcheck package: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 5edf564..4ebb965 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,12 @@ dist/ build/ .pytest_cache/ .ruff_cache/ +.mypy_cache/ +.coverage +.coverage.* +htmlcov/ *.egg CLAUDE.md -copilot-instructions.md # Field test artifacts (kept locally, not in git) runs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 7698ac9..8d90193 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `[tool.ruff]` and `[tool.mypy]` configuration in `pyproject.toml`. Ruff lints `src` and `tests` with an explicit `E, F, I, UP, B` selection at line length 127 (`E501` ignored for unavoidable long string literals in JSON fixtures and schema text). Mypy runs `strict` against `src/skillcheck` under `python_version = "3.10"`, with an `ignore_missing_imports` override for the untyped `tiktoken` dependency and the `tomllib` 3.11+ stdlib backport branch. The source was brought clean under both with real annotations, not blanket ignores. +- Test coverage measurement via `pytest-cov` (added to the `dev` extra). `[tool.coverage.run]` and `[tool.coverage.report]` configure the run, and `addopts` in `[tool.pytest.ini_options]` adds `--cov=skillcheck --cov-report=term-missing --cov-fail-under=68`, so the floor is enforced on every local run and in CI (which runs the same `pytest`). The floor sits a few points under the ~72% measured on CPython 3.10 to absorb matrix variance: the CLI modules run in subprocesses the in-process tracer does not see, the `tomllib` vs fallback-parser branch flips between Python 3.10 and 3.11+, and a few tests skip on Windows. + +### Changed + +- CI `lint` job now enforces `ruff check src tests` and `mypy src/skillcheck` (strict) on every push and pull request, replacing the prior `compileall`-only check. Either tool reporting a finding fails the job. +- `cli.py` split to separate argument wiring from command execution. Parser construction, `skillcheck.toml` application, mode-conflict dispatch, and `main` stay in `cli.py`; the per-mode handlers (emit prompts and graphs, `--show-history`, the default validation pipeline) and the path and ingest IO helpers move to a new `skillcheck.commands` module. `skillcheck.cli:main` and the `skillcheck` console script are unchanged. Pure refactor, no behavior change. + ## [1.4.0] - 2026-05-27 ### Changed diff --git a/pyproject.toml b/pyproject.toml index a43b154..e3e3fe5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ classifiers = [ dev = [ "mypy>=1.10", "pytest>=8.0", + "pytest-cov>=5.0", "ruff>=0.6", "types-PyYAML>=6.0", ] @@ -48,3 +49,41 @@ packages = ["src/skillcheck"] [tool.pytest.ini_options] testpaths = ["tests"] +# Coverage floor sits a few points under the ~72% measured on CPython 3.10 so it +# survives matrix variance: the CLI modules (cli.py, commands.py) run in +# subprocesses the in-process tracer does not see, the tomllib vs fallback-parser +# branch flips between 3.10 and 3.11+, and a few tests skip on Windows. +addopts = "--cov=skillcheck --cov-report=term-missing --cov-fail-under=68" + +[tool.ruff] +target-version = "py310" +line-length = 127 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] +ignore = ["E501"] # line-length in unchangeable string literals (JSON fixtures, base64, schema text) + +[tool.mypy] +strict = true +python_version = "3.10" +files = ["src/skillcheck"] + +[[tool.mypy.overrides]] +# tiktoken ships no type stubs; tomllib has no typeshed stubs under the pinned +# python_version (3.10), where it is the optional 3.11+ stdlib backport branch. +module = ["tiktoken", "tomllib"] +ignore_missing_imports = true + +[tool.coverage.run] +source = ["skillcheck"] +# __main__.py is the `python -m skillcheck` shim; it only runs in a subprocess, +# so the in-process test run never executes it. +omit = ["*/__main__.py"] + +[tool.coverage.report] +show_missing = true +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", +] diff --git a/src/skillcheck/agents/codex.py b/src/skillcheck/agents/codex.py index 9f03a5c..08986ce 100644 --- a/src/skillcheck/agents/codex.py +++ b/src/skillcheck/agents/codex.py @@ -18,10 +18,10 @@ from skillcheck.agents.base import ( SCHEMA_VERSION, + SelfCritiquePrompt, schema_reference, worked_example, ) -from skillcheck.agents.base import SelfCritiquePrompt from skillcheck.parser import ParsedSkill diff --git a/src/skillcheck/agents/cursor.py b/src/skillcheck/agents/cursor.py index 243b244..f1ab20a 100644 --- a/src/skillcheck/agents/cursor.py +++ b/src/skillcheck/agents/cursor.py @@ -26,9 +26,9 @@ from skillcheck.agents.base import ( SCHEMA_VERSION, + SelfCritiquePrompt, compact_schema_signature, ) -from skillcheck.agents.base import SelfCritiquePrompt from skillcheck.parser import ParsedSkill diff --git a/src/skillcheck/agents/graph_parser.py b/src/skillcheck/agents/graph_parser.py index 893b46d..d473560 100644 --- a/src/skillcheck/agents/graph_parser.py +++ b/src/skillcheck/agents/graph_parser.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +from collections.abc import Mapping from typing import Literal from skillcheck.agents._response_text import strip_response_noise @@ -102,7 +103,7 @@ class GraphValueError(GraphParseError): def _require_field( - obj: dict, + obj: dict[str, object], key: str, expected_type: type | tuple[type, ...], context: str = "", @@ -140,7 +141,7 @@ def _require_field( raise GraphSchemaError( f"Field '{full_key}' must be int or null, got bool: {value!r}" ) - if not isinstance(value, expected_type): # type: ignore[arg-type] + if not isinstance(value, expected_type): if isinstance(expected_type, tuple): type_name = " or ".join( "null" if t is type(None) else t.__name__ for t in expected_type @@ -154,7 +155,7 @@ def _require_field( return value -def _check_no_extra_fields(obj: dict, allowed: dict, context: str) -> None: +def _check_no_extra_fields(obj: Mapping[str, object], allowed: Mapping[str, object], context: str) -> None: extra = set(obj) - set(allowed) if extra: raise GraphSchemaError( diff --git a/src/skillcheck/agents/parser.py b/src/skillcheck/agents/parser.py index 9b1abe1..ddcff37 100644 --- a/src/skillcheck/agents/parser.py +++ b/src/skillcheck/agents/parser.py @@ -73,7 +73,7 @@ class CritiqueValueError(CritiqueParseError): """ -def _require_field(obj: dict, key: str, expected_type: type | tuple[type, ...], context: str = "") -> object: +def _require_field(obj: dict[str, object], key: str, expected_type: type | tuple[type, ...], context: str = "") -> object: """Extract a required field with type checking. Args: @@ -98,7 +98,7 @@ def _require_field(obj: dict, key: str, expected_type: type | tuple[type, ...], raise CritiqueSchemaError( f"Field '{full_key}' must be int, got bool: {value!r}" ) - if not isinstance(value, expected_type): # type: ignore[arg-type] + if not isinstance(value, expected_type): type_name = ( expected_type.__name__ if isinstance(expected_type, type) diff --git a/src/skillcheck/cli.py b/src/skillcheck/cli.py index a7598bc..ca8bf16 100644 --- a/src/skillcheck/cli.py +++ b/src/skillcheck/cli.py @@ -1,68 +1,22 @@ from __future__ import annotations import argparse -import json import sys from pathlib import Path from skillcheck import __version__ from skillcheck import config as runtime_config -from skillcheck.config_loader import ConfigError, find_config, load_config -from skillcheck.core import ( - append_run, - build_entry, - check_regression, - extract_graph_agent, - extract_graph_heuristic, - generate_activation_hypotheses, - ingest_critique_response, - ledger_path_for, - load_ledger, - merge_critique_diagnostics, - merge_diagnostics, - render_critique_prompt, - render_activation_json, - render_activation_markdown, - render_activation_text, - render_graph_json, - render_graph_text, - render_ledger_json, - render_ledger_text, - run_divergence_analyzers, - run_graph_analyzers, - validate, - LedgerError, - RunAgents, - ValidationModes, -) -from skillcheck.agents import get_graph_prompt -from skillcheck.agents.graph_parser import GraphParseError -from skillcheck.agents.parser import CritiqueParseError -from skillcheck.formatters import ( - _format_agent, - _format_github, - _format_json, - _format_markdown, - _format_text, +from skillcheck.commands import ( + emit_activation, + emit_agent_reason_packet, + emit_critique_prompts, + emit_graph, + emit_graph_prompts, + resolve_paths, + run_show_history, + run_validation, ) -from skillcheck.parser import parse as _parse_skill -from skillcheck.result import Diagnostic, Severity - -# --------------------------------------------------------------------------- -# Path collection -# --------------------------------------------------------------------------- - - -def _collect_paths(target: Path) -> list[Path]: - """Return a list of SKILL.md files to validate. - - For a directory, recursively finds all files named exactly 'SKILL.md'. - For a file, returns it directly without name filtering. - """ - if target.is_dir(): - return sorted(target.rglob("SKILL.md")) - return [target] - +from skillcheck.config_loader import ConfigError, find_config, load_config # --------------------------------------------------------------------------- # Argument parser @@ -86,15 +40,6 @@ def _collect_paths(target: Path) -> list[Path]: skillcheck SKILL.md --ingest-critique r.json ingest agent response and merge diagnostics """ -# Delimiter used between prompts when emitting for multiple skills. -_PROMPT_DELIMITER = "# === skillcheck:critique-prompt:{path} ===" - -# Delimiter used between graph renders when emitting for multiple skills. -_GRAPH_DELIMITER = "# === skillcheck:graph:{path} ===" - -# Delimiter used between graph-extraction prompts when emitting for multiple skills. -_GRAPH_PROMPT_DELIMITER = "# === skillcheck:graph-prompt:{path} ===" - def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( @@ -347,155 +292,10 @@ def _build_parser() -> argparse.ArgumentParser: # --------------------------------------------------------------------------- -# Entry point +# Config application # --------------------------------------------------------------------------- -def _resolve_paths(args: argparse.Namespace) -> list[Path]: - """Resolve the target paths to a list of SKILL.md files, exiting on error.""" - all_paths: list[Path] = [] - for target in args.path: - if not target.exists(): - print(f"Error: path not found: {target}", file=sys.stderr) - sys.exit(2) - paths = _collect_paths(target) - if not paths: - print(f"No SKILL.md files found under: {target}", file=sys.stderr) - sys.exit(2) - all_paths.extend(paths) - return all_paths - - -def _do_emit_graph(paths: list[Path], fmt: str) -> None: - """Print the capability graph for each path and exit 0.""" - multiple = len(paths) > 1 - for path in paths: - skill = _parse_skill(path) - graph = extract_graph_heuristic(skill) - if multiple: - print(_GRAPH_DELIMITER.format(path=path)) - if fmt == "json": - print(render_graph_json(graph)) - else: - print(render_graph_text(graph)) - - -def _do_emit_critique_prompts(paths: list[Path], fmt: str, agent_id: str = "claude") -> None: - """Print critique prompts to stdout and exit 0.""" - multiple = len(paths) > 1 - for path in paths: - skill = _parse_skill(path) - prompt = render_critique_prompt(skill, agent_id=agent_id) - if multiple: - print(_PROMPT_DELIMITER.format(path=path)) - if fmt == "json": - print(json.dumps({"prompt": prompt})) - elif fmt == "md": - print(f"# skillcheck critique prompt\n\n```text\n{prompt}\n```") - elif fmt == "agent": - print("skillcheck critique prompt") - print("return_json_only: true") - print(prompt) - else: - print(prompt) - - -def _read_ingest_raw(ingest_path: str) -> str: - """Read the raw critique response from PATH or stdin, exiting on error.""" - if ingest_path == "-": - return sys.stdin.read() - p = Path(ingest_path) - if not p.exists(): - print(f"Error: critique response file not found: {p}", file=sys.stderr) - sys.exit(2) - try: - return p.read_text(encoding="utf-8") - except OSError as exc: - print(f"Error: cannot read {p}: {exc}", file=sys.stderr) - sys.exit(2) - - -def _do_emit_graph_prompts(paths: list[Path], fmt: str, agent_id: str = "claude") -> None: - """Print graph-extraction prompts to stdout and exit 0.""" - multiple = len(paths) > 1 - for path in paths: - skill = _parse_skill(path) - prompt = get_graph_prompt(agent_id).render(skill) - if multiple: - print(_GRAPH_PROMPT_DELIMITER.format(path=path)) - if fmt == "json": - print(json.dumps({"prompt": prompt})) - elif fmt == "md": - print(f"# skillcheck graph prompt\n\n```text\n{prompt}\n```") - elif fmt == "agent": - print("skillcheck graph prompt") - print("return_json_only: true") - print(prompt) - else: - print(prompt) - - -def _do_emit_agent_reason_packet(paths: list[Path], fmt: str, critique_agent: str, graph_agent: str) -> None: - """Print a combined critique and graph prompt packet for in-agent execution.""" - packets: list[dict[str, str]] = [] - for path in paths: - skill = _parse_skill(path) - packets.append({ - "path": str(path), - "critique_prompt": render_critique_prompt(skill, agent_id=critique_agent), - "graph_prompt": get_graph_prompt(graph_agent).render(skill), - }) - - if fmt == "json": - print(json.dumps({"agent_reason": packets}, indent=2)) - return - - for index, packet in enumerate(packets, start=1): - if len(packets) > 1: - print(f"# === skillcheck:agent-reason:{packet['path']} ===") - if fmt == "md": - print(f"# Agent Reason Packet {index}\n") - print(f"Path: `{packet['path']}`\n") - print("## Critique prompt\n") - print(f"```text\n{packet['critique_prompt']}\n```\n") - print("## Graph prompt\n") - print(f"```text\n{packet['graph_prompt']}\n```") - elif fmt == "agent": - print("skillcheck agent-reason packet") - print(f"path: {packet['path']}") - print("task: run both prompts, save each JSON response, then invoke skillcheck with --ingest-critique and --ingest-graph") - print("critique_prompt:") - print(packet["critique_prompt"]) - print("graph_prompt:") - print(packet["graph_prompt"]) - else: - print("Critique prompt:") - print(packet["critique_prompt"]) - print("\nGraph prompt:") - print(packet["graph_prompt"]) - - -def _do_emit_activation(paths: list[Path], fmt: str) -> None: - """Print activation hypotheses for each path and exit 0.""" - multiple = len(paths) > 1 - reports = [generate_activation_hypotheses(_parse_skill(path)) for path in paths] - if fmt == "json": - if multiple: - payload = [json.loads(render_activation_json(report)) for report in reports] - print(json.dumps({"activation_reports": payload}, indent=2)) - else: - print(render_activation_json(reports[0])) - return - - for path, report in zip(paths, reports): - if multiple: - print(f"# === skillcheck:activation:{path} ===") - if fmt == "md": - print(render_activation_markdown(report)) - else: - print(render_activation_text(report)) - - def _apply_config(args: argparse.Namespace, parser: argparse.ArgumentParser) -> None: """Apply skillcheck.toml defaults to parsed args.""" config_path = args.config or find_config(args.path[0]) @@ -542,6 +342,11 @@ def _apply_config(args: argparse.Namespace, parser: argparse.ArgumentParser) -> args.graph_agent = loaded_config.graph_agent +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + def main() -> None: # Ensure UTF-8 output on Windows where the default encoding may be cp1252. if sys.stdout.encoding and sys.stdout.encoding.lower().replace("-", "") != "utf8": @@ -709,302 +514,33 @@ def _die_on_mode_conflict() -> None: if args.graph_agent is not None and not args.emit_graph_prompt and not args.agent_reason and args.ingest_graph is None: parser.error("--graph-agent requires --emit-graph-prompt or --ingest-graph") - paths = _resolve_paths(args) + paths = resolve_paths(args) # --show-history: read the ledger for the first path, print it, and exit. - # Each skill has its own per-skill ledger, so multi-target invocations - # cannot map onto a single ledger render; the additional paths are - # ignored with a stderr warning so the silent skip is visible. if args.show_history: - if len(paths) > 1: - print( - f"warning: --show-history reads one ledger; ignoring extra paths: " - f"{', '.join(str(p) for p in paths[1:])}. " - f"Run --show-history once per skill to read its ledger.", - file=sys.stderr, - ) - target_path = paths[0] - lp = ledger_path_for(target_path) - if not lp.exists(): - print( - f"No history ledger found for {target_path}. " - f"Run 'skillcheck {target_path} --history' to start tracking.", - file=sys.stderr, - ) - sys.exit(2) - try: - ledger = load_ledger(lp) - except LedgerError as exc: - print(f"Error: {exc}", file=sys.stderr) - sys.exit(1) - if ledger is None: - print(f"No history ledger found for {target_path}.", file=sys.stderr) - sys.exit(2) - if not args.quiet: - if args.format == "json": - print(render_ledger_json(ledger)) - else: - print(render_ledger_text(ledger)) - sys.exit(0) + run_show_history(args, paths) # --emit-graph: emit the heuristic graph and skip symbolic validation entirely if args.emit_graph: - _do_emit_graph(paths, fmt=args.format) + emit_graph(paths, fmt=args.format) sys.exit(0) # --emit-critique-prompt: skip symbolic validation entirely if args.emit_critique_prompt: - _do_emit_critique_prompts(paths, fmt=args.format, agent_id=agent_id) + emit_critique_prompts(paths, fmt=args.format, agent_id=agent_id) sys.exit(0) # --emit-graph-prompt: render and print the graph-extraction prompt, then exit if args.emit_graph_prompt: - _do_emit_graph_prompts(paths, fmt=args.format, agent_id=graph_agent_id) + emit_graph_prompts(paths, fmt=args.format, agent_id=graph_agent_id) sys.exit(0) if args.agent_reason and args.ingest_critique is None and args.ingest_graph is None: - _do_emit_agent_reason_packet(paths, args.format, agent_id, graph_agent_id) + emit_agent_reason_packet(paths, args.format, agent_id, graph_agent_id) sys.exit(0) if args.activation_hypotheses: - _do_emit_activation(paths, args.format) + emit_activation(paths, args.format) sys.exit(0) - # Run symbolic validation (always, including when ingesting) - results = [ - validate( - p, - max_lines=args.max_lines, - max_tokens=args.max_tokens, - ignore_prefixes=args.ignore_prefixes or None, - skip_dirname_check=args.skip_dirname_check, - skip_ref_check=args.skip_ref_check, - min_desc_score=args.min_desc_score, - strict_vscode=args.strict_vscode, - strict_cursor=args.strict_cursor, - strict_all=args.strict_all, - target_agent=args.target_agent, - ) - for p in paths - ] - - # Track symbolic-only validity BEFORE any ingest merges. Ingest parse - # failures and symbolic errors both exit 1; semantic-only drift exits 3. - symbolic_errors_before_ingest = any(not r.valid for r in results) - - critique_source: str | None = None - graph_source_text: str | None = None - graph_source_json: dict | None = None - any_ingest_failed = False - - if args.ingest_critique is not None: - raw = _read_ingest_raw(args.ingest_critique) - - try: - first_skill = _parse_skill(paths[0]) - critique_diags = ingest_critique_response(first_skill, raw) - except CritiqueParseError as exc: - critique_diags = [ - Diagnostic( - rule="semantic.ingest.parse_error", - severity=Severity.ERROR, - message=str(exc), - ) - ] - any_ingest_failed = True - - results = [merge_critique_diagnostics(r, critique_diags) for r in results] - critique_source = agent_id - - if args.ingest_graph is not None: - raw_graph = _read_ingest_raw(args.ingest_graph) - - try: - first_skill = _parse_skill(paths[0]) - agent_graph = extract_graph_agent(first_skill, raw_graph) - heuristic_graph = extract_graph_heuristic(first_skill) - agent_graph_diags = run_graph_analyzers(agent_graph) - divergence_diags = run_divergence_analyzers(agent_graph, heuristic_graph) - all_graph_diags = agent_graph_diags + divergence_diags - graph_source_text = f"agent ({graph_agent_id})" - graph_source_json = {"mode": "agent", "agent": graph_agent_id} - except GraphParseError as exc: - all_graph_diags = [ - Diagnostic( - rule="semantic.ingest.graph_parse_error", - severity=Severity.ERROR, - message=str(exc), - ) - ] - any_ingest_failed = True - - for i, result in enumerate(results): - results[i] = merge_diagnostics(result, all_graph_diags) - - elif args.analyze_graph: - for i, (path, result) in enumerate(zip(paths, results)): - skill = _parse_skill(path) - graph = extract_graph_heuristic(skill) - graph_diags = run_graph_analyzers(graph) - results[i] = merge_diagnostics(result, graph_diags) - graph_source_text = "heuristic" - graph_source_json = {"mode": "heuristic"} - - # Determine exit code based on current results (before history processing). - # Exit 1: symbolic rules failed before any ingest, or an ingest parse failed. - if symbolic_errors_before_ingest or any_ingest_failed: - final_exit_code = 1 - elif any( - d.severity == Severity.ERROR - for r in results - for d in r.diagnostics - if d.rule.startswith("semantic.") - ): - # Exit 3: symbolic passed, all parses succeeded, but a critique ingest added a - # semantic-namespace contradiction (semantic.*). - final_exit_code = 3 - elif any(not r.valid for r in results): - # Exit 1: any remaining errors (e.g. graph.contradiction from agent ingest). - final_exit_code = 1 - elif any( - d.severity == Severity.WARNING - for r in results - for d in r.diagnostics - ): - # Warning-only runs are a clean pass by default; --strict - # escalates them to exit 1 for stricter CI gates. Exit 2 stays - # reserved for tool-misuse / input errors so CI can distinguish them. - final_exit_code = 1 if args.strict_all else 0 - else: - final_exit_code = 0 - - # --history: for each target, run a regression check against prior runs in - # that target's own ledger and append the ledger entry. Each SKILL.md has - # its own per-skill .skillcheck-history.json next to it (see - # ledger_path_for), so the per-file loop writes one ledger per target. This - # must happen BEFORE the final print so regression diagnostics appear in - # output. --fail-on-regression escalates when any target regressed. - if args.history: - modes = ValidationModes( - symbolic=True, - critique=args.ingest_critique is not None, - graph=args.ingest_graph is not None or args.analyze_graph, - ) - run_agents = RunAgents( - critique_agent=agent_id if args.ingest_critique is not None else None, - graph_agent=graph_agent_id if args.ingest_graph is not None else None, - ) - for index, path in enumerate(paths): - skill_for_history = _parse_skill(path) - preliminary_entry = build_entry( - skill_for_history, - results[index], - modes, - run_agents, - final_exit_code, - __version__, - ) - lp = ledger_path_for(path) - try: - prior_ledger = load_ledger(lp) - prior_runs = prior_ledger.runs if prior_ledger is not None else () - regression_diags = check_regression(prior_runs, preliminary_entry) - except LedgerError as exc: - regression_diags = [ - Diagnostic( - rule="history.read.failed", - severity=Severity.WARNING, - message=f"Could not read history ledger: {exc}", - ) - ] - if regression_diags: - results[index] = merge_diagnostics(results[index], regression_diags) - # Regression is WARNING by default; does not change the exit - # code. --fail-on-regression promotes it to exit 1 the first - # time any target regresses, and that escalated code is what - # subsequent ledger entries (and the global exit) record. - if args.fail_on_regression and any( - d.rule == "history.skill.regressed" for d in regression_diags - ): - final_exit_code = 1 - - # Build final entry with all diagnostics included (regression if any). - final_entry = build_entry( - skill_for_history, - results[index], - modes, - run_agents, - final_exit_code, - __version__, - ) - try: - append_run(lp, skill_for_history, final_entry) - except LedgerError as exc: - results[index] = merge_diagnostics(results[index], [ - Diagnostic( - rule="history.write.failed", - severity=Severity.WARNING, - message=f"Could not write history ledger to {lp}: {exc}", - ) - ]) - # Write failure is a warning; validation exit code stands. - - # Compute description quality score breakdowns when relevant. - score_breakdowns: dict[str, dict[str, int]] = {} - has_quality_diag = any( - d.rule == "description.quality-score" - for r in results - for d in r.diagnostics - ) - if has_quality_diag: - from skillcheck.rules.description import score_description as _score - from skillcheck.template_detection import is_template - for r in results: - for d in r.diagnostics: - if d.rule == "description.quality-score": - try: - skill = _parse_skill(r.path) - desc = skill.frontmatter.get("description") - if desc and isinstance(desc, str) and desc.strip() and not is_template(skill): - _, _, bd = _score(desc) - score_breakdowns[str(r.path)] = bd - except Exception: - pass - break # one description per file - - # Print report (after history processing so regression/write-fail diagnostics appear). - if not args.quiet: - if args.format == "json": - print(_format_json( - results, - __version__, - critique_source=critique_source, - graph_source=graph_source_json, - score_breakdowns=score_breakdowns or None, - )) - elif args.format == "md": - print(_format_markdown( - results, - critique_source=critique_source, - graph_source=graph_source_text, - )) - elif args.format == "agent": - print(_format_agent( - results, - critique_source=critique_source, - graph_source=graph_source_text, - )) - elif args.format == "github": - print(_format_github(results)) - else: - use_color = not args.no_color and sys.stdout.isatty() - print(_format_text( - results, - color=use_color, - critique_source=critique_source, - graph_source=graph_source_text, - score_breakdowns=score_breakdowns or None, - explain_score=args.explain_score, - )) - - sys.exit(final_exit_code) + run_validation(args, paths, agent_id, graph_agent_id) diff --git a/src/skillcheck/commands.py b/src/skillcheck/commands.py new file mode 100644 index 0000000..f823508 --- /dev/null +++ b/src/skillcheck/commands.py @@ -0,0 +1,529 @@ +"""Command execution for the skillcheck CLI. + +`cli.py` owns argument wiring (parser construction, config application, dispatch). +This module owns what each mode does: collecting paths, reading ingest payloads, +emitting prompts and graphs, printing the history ledger, and running the default +validation pipeline. The functions here carry their own exit codes via +`sys.exit`, matching the behavior they had when they lived in `cli.py`. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +from skillcheck import __version__ +from skillcheck.agents import get_graph_prompt +from skillcheck.agents.graph_parser import GraphParseError +from skillcheck.agents.parser import CritiqueParseError +from skillcheck.core import ( + LedgerError, + RunAgents, + ValidationModes, + append_run, + build_entry, + check_regression, + extract_graph_agent, + extract_graph_heuristic, + generate_activation_hypotheses, + ingest_critique_response, + ledger_path_for, + load_ledger, + merge_critique_diagnostics, + merge_diagnostics, + render_activation_json, + render_activation_markdown, + render_activation_text, + render_critique_prompt, + render_graph_json, + render_graph_text, + render_ledger_json, + render_ledger_text, + run_divergence_analyzers, + run_graph_analyzers, + validate, +) +from skillcheck.formatters import ( + _format_agent, + _format_github, + _format_json, + _format_markdown, + _format_text, +) +from skillcheck.parser import parse as _parse_skill +from skillcheck.result import Diagnostic, Severity + +# Delimiter used between prompts when emitting for multiple skills. +_PROMPT_DELIMITER = "# === skillcheck:critique-prompt:{path} ===" + +# Delimiter used between graph renders when emitting for multiple skills. +_GRAPH_DELIMITER = "# === skillcheck:graph:{path} ===" + +# Delimiter used between graph-extraction prompts when emitting for multiple skills. +_GRAPH_PROMPT_DELIMITER = "# === skillcheck:graph-prompt:{path} ===" + + +# --------------------------------------------------------------------------- +# Path collection +# --------------------------------------------------------------------------- + + +def collect_paths(target: Path) -> list[Path]: + """Return a list of SKILL.md files to validate. + + For a directory, recursively finds all files named exactly 'SKILL.md'. + For a file, returns it directly without name filtering. + """ + if target.is_dir(): + return sorted(target.rglob("SKILL.md")) + return [target] + + +def resolve_paths(args: argparse.Namespace) -> list[Path]: + """Resolve the target paths to a list of SKILL.md files, exiting on error.""" + all_paths: list[Path] = [] + for target in args.path: + if not target.exists(): + print(f"Error: path not found: {target}", file=sys.stderr) + sys.exit(2) + paths = collect_paths(target) + if not paths: + print(f"No SKILL.md files found under: {target}", file=sys.stderr) + sys.exit(2) + all_paths.extend(paths) + return all_paths + + +def read_ingest_raw(ingest_path: str) -> str: + """Read the raw critique response from PATH or stdin, exiting on error.""" + if ingest_path == "-": + return sys.stdin.read() + p = Path(ingest_path) + if not p.exists(): + print(f"Error: critique response file not found: {p}", file=sys.stderr) + sys.exit(2) + try: + return p.read_text(encoding="utf-8") + except OSError as exc: + print(f"Error: cannot read {p}: {exc}", file=sys.stderr) + sys.exit(2) + + +# --------------------------------------------------------------------------- +# Emit modes +# --------------------------------------------------------------------------- + + +def emit_graph(paths: list[Path], fmt: str) -> None: + """Print the capability graph for each path and exit 0.""" + multiple = len(paths) > 1 + for path in paths: + skill = _parse_skill(path) + graph = extract_graph_heuristic(skill) + if multiple: + print(_GRAPH_DELIMITER.format(path=path)) + if fmt == "json": + print(render_graph_json(graph)) + else: + print(render_graph_text(graph)) + + +def emit_critique_prompts(paths: list[Path], fmt: str, agent_id: str = "claude") -> None: + """Print critique prompts to stdout and exit 0.""" + multiple = len(paths) > 1 + for path in paths: + skill = _parse_skill(path) + prompt = render_critique_prompt(skill, agent_id=agent_id) + if multiple: + print(_PROMPT_DELIMITER.format(path=path)) + if fmt == "json": + print(json.dumps({"prompt": prompt})) + elif fmt == "md": + print(f"# skillcheck critique prompt\n\n```text\n{prompt}\n```") + elif fmt == "agent": + print("skillcheck critique prompt") + print("return_json_only: true") + print(prompt) + else: + print(prompt) + + +def emit_graph_prompts(paths: list[Path], fmt: str, agent_id: str = "claude") -> None: + """Print graph-extraction prompts to stdout and exit 0.""" + multiple = len(paths) > 1 + for path in paths: + skill = _parse_skill(path) + prompt = get_graph_prompt(agent_id).render(skill) + if multiple: + print(_GRAPH_PROMPT_DELIMITER.format(path=path)) + if fmt == "json": + print(json.dumps({"prompt": prompt})) + elif fmt == "md": + print(f"# skillcheck graph prompt\n\n```text\n{prompt}\n```") + elif fmt == "agent": + print("skillcheck graph prompt") + print("return_json_only: true") + print(prompt) + else: + print(prompt) + + +def emit_agent_reason_packet(paths: list[Path], fmt: str, critique_agent: str, graph_agent: str) -> None: + """Print a combined critique and graph prompt packet for in-agent execution.""" + packets: list[dict[str, str]] = [] + for path in paths: + skill = _parse_skill(path) + packets.append({ + "path": str(path), + "critique_prompt": render_critique_prompt(skill, agent_id=critique_agent), + "graph_prompt": get_graph_prompt(graph_agent).render(skill), + }) + + if fmt == "json": + print(json.dumps({"agent_reason": packets}, indent=2)) + return + + for index, packet in enumerate(packets, start=1): + if len(packets) > 1: + print(f"# === skillcheck:agent-reason:{packet['path']} ===") + if fmt == "md": + print(f"# Agent Reason Packet {index}\n") + print(f"Path: `{packet['path']}`\n") + print("## Critique prompt\n") + print(f"```text\n{packet['critique_prompt']}\n```\n") + print("## Graph prompt\n") + print(f"```text\n{packet['graph_prompt']}\n```") + elif fmt == "agent": + print("skillcheck agent-reason packet") + print(f"path: {packet['path']}") + print("task: run both prompts, save each JSON response, then invoke skillcheck with --ingest-critique and --ingest-graph") + print("critique_prompt:") + print(packet["critique_prompt"]) + print("graph_prompt:") + print(packet["graph_prompt"]) + else: + print("Critique prompt:") + print(packet["critique_prompt"]) + print("\nGraph prompt:") + print(packet["graph_prompt"]) + + +def emit_activation(paths: list[Path], fmt: str) -> None: + """Print activation hypotheses for each path and exit 0.""" + multiple = len(paths) > 1 + reports = [generate_activation_hypotheses(_parse_skill(path)) for path in paths] + if fmt == "json": + if multiple: + payload = [json.loads(render_activation_json(report)) for report in reports] + print(json.dumps({"activation_reports": payload}, indent=2)) + else: + print(render_activation_json(reports[0])) + return + + for path, report in zip(paths, reports, strict=True): + if multiple: + print(f"# === skillcheck:activation:{path} ===") + if fmt == "md": + print(render_activation_markdown(report)) + else: + print(render_activation_text(report)) + + +# --------------------------------------------------------------------------- +# History +# --------------------------------------------------------------------------- + + +def run_show_history(args: argparse.Namespace, paths: list[Path]) -> None: + """Print the validation history ledger for the first path and exit. + + Each skill has its own per-skill ledger, so multi-target invocations cannot + map onto a single ledger render; the additional paths are ignored with a + stderr warning so the silent skip is visible. + """ + if len(paths) > 1: + print( + f"warning: --show-history reads one ledger; ignoring extra paths: " + f"{', '.join(str(p) for p in paths[1:])}. " + f"Run --show-history once per skill to read its ledger.", + file=sys.stderr, + ) + target_path = paths[0] + lp = ledger_path_for(target_path) + if not lp.exists(): + print( + f"No history ledger found for {target_path}. " + f"Run 'skillcheck {target_path} --history' to start tracking.", + file=sys.stderr, + ) + sys.exit(2) + try: + ledger = load_ledger(lp) + except LedgerError as exc: + print(f"Error: {exc}", file=sys.stderr) + sys.exit(1) + if ledger is None: + print(f"No history ledger found for {target_path}.", file=sys.stderr) + sys.exit(2) + if not args.quiet: + if args.format == "json": + print(render_ledger_json(ledger)) + else: + print(render_ledger_text(ledger)) + sys.exit(0) + + +# --------------------------------------------------------------------------- +# Default validation pipeline +# --------------------------------------------------------------------------- + + +def run_validation( + args: argparse.Namespace, + paths: list[Path], + agent_id: str, + graph_agent_id: str, +) -> None: + """Run symbolic validation, merge any ingested diagnostics, record history, + print the report, and exit with the computed code.""" + # Run symbolic validation (always, including when ingesting) + results = [ + validate( + p, + max_lines=args.max_lines, + max_tokens=args.max_tokens, + ignore_prefixes=args.ignore_prefixes or None, + skip_dirname_check=args.skip_dirname_check, + skip_ref_check=args.skip_ref_check, + min_desc_score=args.min_desc_score, + strict_vscode=args.strict_vscode, + strict_cursor=args.strict_cursor, + strict_all=args.strict_all, + target_agent=args.target_agent, + ) + for p in paths + ] + + # Track symbolic-only validity BEFORE any ingest merges. Ingest parse + # failures and symbolic errors both exit 1; semantic-only drift exits 3. + symbolic_errors_before_ingest = any(not r.valid for r in results) + + critique_source: str | None = None + graph_source_text: str | None = None + graph_source_json: dict[str, Any] | None = None + any_ingest_failed = False + + if args.ingest_critique is not None: + raw = read_ingest_raw(args.ingest_critique) + + try: + first_skill = _parse_skill(paths[0]) + critique_diags = ingest_critique_response(first_skill, raw) + except CritiqueParseError as exc: + critique_diags = [ + Diagnostic( + rule="semantic.ingest.parse_error", + severity=Severity.ERROR, + message=str(exc), + ) + ] + any_ingest_failed = True + + results = [merge_critique_diagnostics(r, critique_diags) for r in results] + critique_source = agent_id + + if args.ingest_graph is not None: + raw_graph = read_ingest_raw(args.ingest_graph) + + try: + first_skill = _parse_skill(paths[0]) + agent_graph = extract_graph_agent(first_skill, raw_graph) + heuristic_graph = extract_graph_heuristic(first_skill) + agent_graph_diags = run_graph_analyzers(agent_graph) + divergence_diags = run_divergence_analyzers(agent_graph, heuristic_graph) + all_graph_diags = agent_graph_diags + divergence_diags + graph_source_text = f"agent ({graph_agent_id})" + graph_source_json = {"mode": "agent", "agent": graph_agent_id} + except GraphParseError as exc: + all_graph_diags = [ + Diagnostic( + rule="semantic.ingest.graph_parse_error", + severity=Severity.ERROR, + message=str(exc), + ) + ] + any_ingest_failed = True + + for i, result in enumerate(results): + results[i] = merge_diagnostics(result, all_graph_diags) + + elif args.analyze_graph: + for i, (path, result) in enumerate(zip(paths, results, strict=True)): + skill = _parse_skill(path) + graph = extract_graph_heuristic(skill) + graph_diags = run_graph_analyzers(graph) + results[i] = merge_diagnostics(result, graph_diags) + graph_source_text = "heuristic" + graph_source_json = {"mode": "heuristic"} + + # Determine exit code based on current results (before history processing). + # Exit 1: symbolic rules failed before any ingest, or an ingest parse failed. + if symbolic_errors_before_ingest or any_ingest_failed: + final_exit_code = 1 + elif any( + d.severity == Severity.ERROR + for r in results + for d in r.diagnostics + if d.rule.startswith("semantic.") + ): + # Exit 3: symbolic passed, all parses succeeded, but a critique ingest added a + # semantic-namespace contradiction (semantic.*). + final_exit_code = 3 + elif any(not r.valid for r in results): + # Exit 1: any remaining errors (e.g. graph.contradiction from agent ingest). + final_exit_code = 1 + elif any( + d.severity == Severity.WARNING + for r in results + for d in r.diagnostics + ): + # Warning-only runs are a clean pass by default; --strict + # escalates them to exit 1 for stricter CI gates. Exit 2 stays + # reserved for tool-misuse / input errors so CI can distinguish them. + final_exit_code = 1 if args.strict_all else 0 + else: + final_exit_code = 0 + + # --history: for each target, run a regression check against prior runs in + # that target's own ledger and append the ledger entry. Each SKILL.md has + # its own per-skill .skillcheck-history.json next to it (see + # ledger_path_for), so the per-file loop writes one ledger per target. This + # must happen BEFORE the final print so regression diagnostics appear in + # output. --fail-on-regression escalates when any target regressed. + if args.history: + modes = ValidationModes( + symbolic=True, + critique=args.ingest_critique is not None, + graph=args.ingest_graph is not None or args.analyze_graph, + ) + run_agents = RunAgents( + critique_agent=agent_id if args.ingest_critique is not None else None, + graph_agent=graph_agent_id if args.ingest_graph is not None else None, + ) + for index, path in enumerate(paths): + skill_for_history = _parse_skill(path) + preliminary_entry = build_entry( + skill_for_history, + results[index], + modes, + run_agents, + final_exit_code, + __version__, + ) + lp = ledger_path_for(path) + try: + prior_ledger = load_ledger(lp) + prior_runs = prior_ledger.runs if prior_ledger is not None else () + regression_diags = check_regression(prior_runs, preliminary_entry) + except LedgerError as exc: + regression_diags = [ + Diagnostic( + rule="history.read.failed", + severity=Severity.WARNING, + message=f"Could not read history ledger: {exc}", + ) + ] + if regression_diags: + results[index] = merge_diagnostics(results[index], regression_diags) + # Regression is WARNING by default; does not change the exit + # code. --fail-on-regression promotes it to exit 1 the first + # time any target regresses, and that escalated code is what + # subsequent ledger entries (and the global exit) record. + if args.fail_on_regression and any( + d.rule == "history.skill.regressed" for d in regression_diags + ): + final_exit_code = 1 + + # Build final entry with all diagnostics included (regression if any). + final_entry = build_entry( + skill_for_history, + results[index], + modes, + run_agents, + final_exit_code, + __version__, + ) + try: + append_run(lp, skill_for_history, final_entry) + except LedgerError as exc: + results[index] = merge_diagnostics(results[index], [ + Diagnostic( + rule="history.write.failed", + severity=Severity.WARNING, + message=f"Could not write history ledger to {lp}: {exc}", + ) + ]) + # Write failure is a warning; validation exit code stands. + + # Compute description quality score breakdowns when relevant. + score_breakdowns: dict[str, dict[str, int]] = {} + has_quality_diag = any( + d.rule == "description.quality-score" + for r in results + for d in r.diagnostics + ) + if has_quality_diag: + from skillcheck.rules.description import score_description as _score + from skillcheck.template_detection import is_template + for r in results: + for d in r.diagnostics: + if d.rule == "description.quality-score": + try: + skill = _parse_skill(r.path) + desc = skill.frontmatter.get("description") + if desc and isinstance(desc, str) and desc.strip() and not is_template(skill): + _, _, bd = _score(desc) + score_breakdowns[str(r.path)] = bd + except Exception: + pass + break # one description per file + + # Print report (after history processing so regression/write-fail diagnostics appear). + if not args.quiet: + if args.format == "json": + print(_format_json( + results, + __version__, + critique_source=critique_source, + graph_source=graph_source_json, + score_breakdowns=score_breakdowns or None, + )) + elif args.format == "md": + print(_format_markdown( + results, + critique_source=critique_source, + graph_source=graph_source_text, + )) + elif args.format == "agent": + print(_format_agent( + results, + critique_source=critique_source, + graph_source=graph_source_text, + )) + elif args.format == "github": + print(_format_github(results)) + else: + use_color = not args.no_color and sys.stdout.isatty() + print(_format_text( + results, + color=use_color, + critique_source=critique_source, + graph_source=graph_source_text, + score_breakdowns=score_breakdowns or None, + explain_score=args.explain_score, + )) + + sys.exit(final_exit_code) diff --git a/src/skillcheck/config_loader.py b/src/skillcheck/config_loader.py index 4fd3454..173029f 100644 --- a/src/skillcheck/config_loader.py +++ b/src/skillcheck/config_loader.py @@ -9,7 +9,7 @@ try: # Python 3.11+ import tomllib except ModuleNotFoundError: # pragma: no cover, exercised only on Python 3.10 - tomllib = None # type: ignore[assignment] + tomllib = None @dataclass(frozen=True, slots=True) diff --git a/src/skillcheck/core/__init__.py b/src/skillcheck/core/__init__.py index 8997ffa..7341b13 100644 --- a/src/skillcheck/core/__init__.py +++ b/src/skillcheck/core/__init__.py @@ -1,16 +1,38 @@ from __future__ import annotations -from . import activation, activation_render, graph, graph_analyzers, graph_render, history, reporter, semantic, symbolic +from . import ( + activation, + activation_render, + graph, + graph_analyzers, + graph_render, + history, + reporter, + semantic, + symbolic, +) from .activation import ActivationHypothesis, ActivationReport, generate_activation_hypotheses from .activation_render import ( render_activation_json, render_activation_markdown, render_activation_text, ) +from .graph import ( + Capability, + CapabilityGraph, + Edge, + Input, + Output, + extract_backtick_refs, + extract_graph_agent, + extract_graph_heuristic, +) +from .graph_analyzers import run_divergence_analyzers, run_graph_analyzers +from .graph_render import render_graph_json, render_graph_text from .history import ( + Ledger, LedgerEntry, LedgerError, - Ledger, ResultCounts, RunAgents, ValidationModes, @@ -24,18 +46,6 @@ render_ledger_text, save_ledger, ) -from .graph import ( - Capability, - CapabilityGraph, - Edge, - Input, - Output, - extract_backtick_refs, - extract_graph_agent, - extract_graph_heuristic, -) -from .graph_analyzers import run_divergence_analyzers, run_graph_analyzers -from .graph_render import render_graph_json, render_graph_text from .semantic import ( ingest_critique_response, merge_critique_diagnostics, diff --git a/src/skillcheck/core/history.py b/src/skillcheck/core/history.py index 5e66e3e..6ff31c8 100644 --- a/src/skillcheck/core/history.py +++ b/src/skillcheck/core/history.py @@ -26,6 +26,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path +from typing import Any from skillcheck.parser import ParsedSkill from skillcheck.result import Diagnostic, Severity, ValidationResult @@ -245,7 +246,7 @@ def check_regression( # --------------------------------------------------------------------------- -def _entry_to_dict(entry: LedgerEntry) -> dict: +def _entry_to_dict(entry: LedgerEntry) -> dict[str, object]: """Serialize a LedgerEntry to an ordered dict. Field order matches dataclass.""" return { "timestamp_utc": entry.timestamp_utc, @@ -270,7 +271,7 @@ def _entry_to_dict(entry: LedgerEntry) -> dict: } -def _entry_from_dict(data: dict, path: Path) -> LedgerEntry: +def _entry_from_dict(data: dict[str, Any], path: Path) -> LedgerEntry: """Deserialize a LedgerEntry from a dict. Raises LedgerError on missing keys.""" try: modes_d = data["validation_modes"] diff --git a/src/skillcheck/formatters.py b/src/skillcheck/formatters.py index f374982..4889f4a 100644 --- a/src/skillcheck/formatters.py +++ b/src/skillcheck/formatters.py @@ -106,7 +106,7 @@ def _format_json( results: list[ValidationResult], version: str, critique_source: str | None = None, - graph_source: dict | None = None, + graph_source: dict[str, object] | None = None, score_breakdowns: dict[str, dict[str, int]] | None = None, ) -> str: passed = sum(1 for r in results if r.valid) diff --git a/src/skillcheck/rules/compat.py b/src/skillcheck/rules/compat.py index de56439..fe180ca 100644 --- a/src/skillcheck/rules/compat.py +++ b/src/skillcheck/rules/compat.py @@ -20,8 +20,7 @@ from collections.abc import Callable from skillcheck import config -from skillcheck.parser import _FRONTMATTER_RE -from skillcheck.parser import ParsedSkill +from skillcheck.parser import _FRONTMATTER_RE, ParsedSkill from skillcheck.result import Diagnostic, Severity from skillcheck.template_detection import is_template diff --git a/src/skillcheck/rules/description.py b/src/skillcheck/rules/description.py index 2a76cc6..efbacd3 100644 --- a/src/skillcheck/rules/description.py +++ b/src/skillcheck/rules/description.py @@ -245,7 +245,7 @@ def score_description(desc: str) -> tuple[int, list[str], dict[str, int]]: total = 0 suggestions: list[str] = [] breakdown: dict[str, int] = {} - for name, max_pts, scorer in scorers: + for name, _max_pts, scorer in scorers: points, suggestion = scorer(desc) total += points breakdown[name] = points diff --git a/src/skillcheck/tokenizer.py b/src/skillcheck/tokenizer.py index 7fb887b..c16b2dd 100644 --- a/src/skillcheck/tokenizer.py +++ b/src/skillcheck/tokenizer.py @@ -37,7 +37,7 @@ def _get_tiktoken_enc() -> Any | None: # Re-check under the lock so the second arrival sees the cached value. if _tiktoken_available is None: try: - import tiktoken # type: ignore[import-untyped] + import tiktoken _tiktoken_enc = tiktoken.get_encoding("cl100k_base") _tiktoken_available = True except ImportError: diff --git a/tests/conftest.py b/tests/conftest.py index 66ba83a..add9d81 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,20 @@ +import importlib.util +import sys from pathlib import Path import pytest FIXTURES_DIR = Path(__file__).parent / "fixtures" +# Invoke the CLI through the same interpreter that runs the tests, rather than a +# bare ``skillcheck`` entry on PATH. PATH may resolve to a stale console script +# built for a different Python (e.g. a system 3.9 install that cannot import the +# 3.10+ source), which is non-hermetic and masks the package under test. +SKILLCHECK_CMD = [sys.executable, "-m", "skillcheck"] + +# CLI tests are skipped when the package is not importable in this interpreter. +CLI_AVAILABLE = importlib.util.find_spec("skillcheck") is not None + @pytest.fixture def fixtures_dir() -> Path: diff --git a/tests/test_agent_prompts.py b/tests/test_agent_prompts.py index 076cc0a..4afee00 100644 --- a/tests/test_agent_prompts.py +++ b/tests/test_agent_prompts.py @@ -7,7 +7,7 @@ import pytest from skillcheck.agents import AGENTS, get_agent_prompt -from skillcheck.agents.base import SelfCritiquePrompt, worked_example +from skillcheck.agents.base import worked_example from skillcheck.agents.claude import ClaudePrompt from skillcheck.agents.codex import CodexPrompt from skillcheck.agents.cursor import CursorPrompt @@ -189,8 +189,6 @@ def test_different_agents_produce_different_output() -> None: def test_worked_example_is_valid_critique_json() -> None: # worked_example() is embedded in claude/codex prompts; it must be parseable. - from skillcheck.agents.parser import parse_critique_response - import json # Extract just the JSON object from the worked example. example_text = worked_example() diff --git a/tests/test_agent_prompts_smoke.py b/tests/test_agent_prompts_smoke.py index 8ff8006..a69726d 100644 --- a/tests/test_agent_prompts_smoke.py +++ b/tests/test_agent_prompts_smoke.py @@ -33,7 +33,6 @@ from skillcheck.parser import parse from tests.conftest import FIXTURES_DIR - _FIXTURE = FIXTURES_DIR / "valid_basic.md" diff --git a/tests/test_cli.py b/tests/test_cli.py index e439f56..ee9ad40 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,22 +1,20 @@ import json -import shutil import subprocess -import sys import pytest -from tests.conftest import FIXTURES_DIR +from tests.conftest import CLI_AVAILABLE, FIXTURES_DIR, SKILLCHECK_CMD -# Skip all CLI tests if the package is not installed as a command. +# Skip all CLI tests if the package is not importable in this interpreter. pytestmark = pytest.mark.skipif( - shutil.which("skillcheck") is None, + not CLI_AVAILABLE, reason="skillcheck not installed; run `pip install -e .` first", ) def run(*args: str) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", *args], + [*SKILLCHECK_CMD, *args], capture_output=True, text=True, encoding="utf-8", @@ -26,7 +24,7 @@ def run(*args: str) -> subprocess.CompletedProcess: def run_fixture(*args: str) -> subprocess.CompletedProcess: """Run skillcheck with --skip-dirname-check for fixture files.""" return subprocess.run( - ["skillcheck", "--skip-dirname-check", *args], + [*SKILLCHECK_CMD, "--skip-dirname-check", *args], capture_output=True, text=True, encoding="utf-8", diff --git a/tests/test_cli_critique.py b/tests/test_cli_critique.py index 8ca191b..f6fe19a 100644 --- a/tests/test_cli_critique.py +++ b/tests/test_cli_critique.py @@ -3,26 +3,24 @@ from __future__ import annotations import json -import shutil import subprocess -import sys from pathlib import Path import pytest -from tests.conftest import FIXTURES_DIR +from tests.conftest import CLI_AVAILABLE, FIXTURES_DIR, SKILLCHECK_CMD CRITIQUE_DIR = FIXTURES_DIR / "critique" pytestmark = pytest.mark.skipif( - shutil.which("skillcheck") is None, + not CLI_AVAILABLE, reason="skillcheck not installed; run `pip install -e .` first", ) def run(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", *args], + [*SKILLCHECK_CMD, *args], capture_output=True, text=True, encoding="utf-8", @@ -32,7 +30,7 @@ def run(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess: def run_fixture(*args: str, stdin: str | None = None) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", "--skip-dirname-check", *args], + [*SKILLCHECK_CMD, "--skip-dirname-check", *args], capture_output=True, text=True, encoding="utf-8", @@ -117,7 +115,7 @@ def test_emit_critique_prompt_directory_contains_delimiters(tmp_path: Path) -> N ) result = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(tmp_path), "--emit-critique-prompt"], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(tmp_path), "--emit-critique-prompt"], capture_output=True, text=True, encoding="utf-8", diff --git a/tests/test_cli_critique_agent.py b/tests/test_cli_critique_agent.py index 27577bc..faa8da8 100644 --- a/tests/test_cli_critique_agent.py +++ b/tests/test_cli_critique_agent.py @@ -3,17 +3,18 @@ from __future__ import annotations import json -import shutil import subprocess from pathlib import Path import pytest +from tests.conftest import CLI_AVAILABLE, SKILLCHECK_CMD + FIXTURES_DIR = Path(__file__).parent / "fixtures" CRITIQUE_DIR = FIXTURES_DIR / "critique" pytestmark = pytest.mark.skipif( - shutil.which("skillcheck") is None, + not CLI_AVAILABLE, reason="skillcheck not installed; run `pip install -e .` first", ) @@ -24,7 +25,7 @@ def run(*args: str) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", *_FLAGS, *args], + [*SKILLCHECK_CMD, *_FLAGS, *args], capture_output=True, text=True, encoding="utf-8", diff --git a/tests/test_cli_graph.py b/tests/test_cli_graph.py index 2ce2d9e..00a56b9 100644 --- a/tests/test_cli_graph.py +++ b/tests/test_cli_graph.py @@ -7,16 +7,15 @@ from __future__ import annotations import json -import shutil import subprocess from pathlib import Path import pytest -from tests.conftest import FIXTURES_DIR +from tests.conftest import CLI_AVAILABLE, FIXTURES_DIR, SKILLCHECK_CMD pytestmark = pytest.mark.skipif( - shutil.which("skillcheck") is None, + not CLI_AVAILABLE, reason="skillcheck not installed; run `pip install -e .` first", ) @@ -25,7 +24,7 @@ def _run(*args: str) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", "--skip-dirname-check", *args], + [*SKILLCHECK_CMD, "--skip-dirname-check", *args], capture_output=True, text=True, encoding="utf-8", diff --git a/tests/test_cli_graph_agent.py b/tests/test_cli_graph_agent.py index 6c82be6..4445223 100644 --- a/tests/test_cli_graph_agent.py +++ b/tests/test_cli_graph_agent.py @@ -3,19 +3,20 @@ from __future__ import annotations import json -import shutil import subprocess from pathlib import Path import pytest +from tests.conftest import CLI_AVAILABLE, SKILLCHECK_CMD + FIXTURES_DIR = Path(__file__).parent / "fixtures" GR_DIR = FIXTURES_DIR / "graph_responses" GRAPH_DIR = FIXTURES_DIR / "graph" CRITIQUE_DIR = FIXTURES_DIR / "critique" pytestmark = pytest.mark.skipif( - shutil.which("skillcheck") is None, + not CLI_AVAILABLE, reason="skillcheck not installed; run `pip install -e .` first", ) @@ -31,7 +32,7 @@ def run(*args: str) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", *_FLAGS, *args], + [*SKILLCHECK_CMD, *_FLAGS, *args], capture_output=True, text=True, encoding="utf-8", diff --git a/tests/test_cli_history.py b/tests/test_cli_history.py index 5d2f342..1a5a559 100644 --- a/tests/test_cli_history.py +++ b/tests/test_cli_history.py @@ -12,13 +12,15 @@ import pytest +from tests.conftest import CLI_AVAILABLE, SKILLCHECK_CMD + FIXTURES_DIR = Path(__file__).parent / "fixtures" CRITIQUE_DIR = FIXTURES_DIR / "critique" GR_DIR = FIXTURES_DIR / "graph_responses" HISTORY_DIR = FIXTURES_DIR / "history" pytestmark = pytest.mark.skipif( - shutil.which("skillcheck") is None, + not CLI_AVAILABLE, reason="skillcheck not installed; run `pip install -e .` first", ) @@ -27,7 +29,7 @@ def run(*args: str) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", *_BASE_FLAGS, *args], + [*SKILLCHECK_CMD, *_BASE_FLAGS, *args], capture_output=True, text=True, encoding="utf-8", @@ -130,7 +132,7 @@ def test_history_records_both_critique_and_graph_agents(tmp_path: Path): shutil.copy(FIXTURES_DIR / "valid_basic.md", skill) critique_response = str(CRITIQUE_DIR / "response_clean.json") graph_response = str(GR_DIR / "response_clean.json") - result = run( + run( str(skill), "--history", "--ingest-critique", critique_response, @@ -281,7 +283,7 @@ def test_history_regression_emits_warning(tmp_path: Path): # Now run with --history on the same bad_desc_empty.md which will fail. result = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(bad_skill), "--history"], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(bad_skill), "--history"], capture_output=True, text=True, encoding="utf-8", @@ -352,7 +354,7 @@ def test_history_write_failure_warns_but_keeps_exit_code(tmp_path: Path): try: os.chmod(tmp_path, stat.S_IRUSR | stat.S_IXUSR) result = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(skill), "--history"], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(skill), "--history"], capture_output=True, text=True, encoding="utf-8", @@ -376,14 +378,14 @@ def test_text_output_unchanged_no_history_flag(tmp_path: Path): skill = tmp_path / "SKILL.md" shutil.copy(FIXTURES_DIR / "valid_basic.md", skill) result_with = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(skill)], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(skill)], capture_output=True, text=True, encoding="utf-8", ) # Baseline: run twice without --history and confirm output is stable. result_again = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(skill)], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(skill)], capture_output=True, text=True, encoding="utf-8", @@ -396,11 +398,11 @@ def test_json_output_unchanged_no_history_flag(tmp_path: Path): skill = tmp_path / "SKILL.md" shutil.copy(FIXTURES_DIR / "valid_basic.md", skill) r1 = subprocess.run( - ["skillcheck", "--skip-dirname-check", "--format", "json", str(skill)], + [*SKILLCHECK_CMD, "--skip-dirname-check", "--format", "json", str(skill)], capture_output=True, text=True, encoding="utf-8", ) r2 = subprocess.run( - ["skillcheck", "--skip-dirname-check", "--format", "json", str(skill)], + [*SKILLCHECK_CMD, "--skip-dirname-check", "--format", "json", str(skill)], capture_output=True, text=True, encoding="utf-8", ) assert r1.stdout == r2.stdout @@ -412,11 +414,11 @@ def test_ingest_critique_alone_unchanged(tmp_path: Path): shutil.copy(FIXTURES_DIR / "valid_basic.md", skill) critique = str(CRITIQUE_DIR / "response_clean.json") r1 = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(skill), "--ingest-critique", critique], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(skill), "--ingest-critique", critique], capture_output=True, text=True, encoding="utf-8", ) r2 = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(skill), "--ingest-critique", critique], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(skill), "--ingest-critique", critique], capture_output=True, text=True, encoding="utf-8", ) assert r1.returncode == r2.returncode @@ -427,11 +429,11 @@ def test_ingest_graph_alone_unchanged(tmp_path: Path): shutil.copy(FIXTURES_DIR / "valid_basic.md", skill) graph_r = str(GR_DIR / "response_clean.json") r1 = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(skill), "--ingest-graph", graph_r], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(skill), "--ingest-graph", graph_r], capture_output=True, text=True, encoding="utf-8", ) r2 = subprocess.run( - ["skillcheck", "--skip-dirname-check", str(skill), "--ingest-graph", graph_r], + [*SKILLCHECK_CMD, "--skip-dirname-check", str(skill), "--ingest-graph", graph_r], capture_output=True, text=True, encoding="utf-8", ) assert r1.returncode == r2.returncode diff --git a/tests/test_compat.py b/tests/test_compat.py index c7a211d..d462ffc 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -16,7 +16,6 @@ ) from tests.conftest import FIXTURES_DIR - # --------------------------------------------------------------------------- # compat.claude-only # --------------------------------------------------------------------------- diff --git a/tests/test_critique_prompt.py b/tests/test_critique_prompt.py index 16dead7..8691b37 100644 --- a/tests/test_critique_prompt.py +++ b/tests/test_critique_prompt.py @@ -4,8 +4,6 @@ from pathlib import Path -import pytest - from skillcheck.agents.base import SCHEMA_VERSION, SelfCritiquePrompt from skillcheck.parser import parse diff --git a/tests/test_critique_schema.py b/tests/test_critique_schema.py index 25556f3..5fbb6dc 100644 --- a/tests/test_critique_schema.py +++ b/tests/test_critique_schema.py @@ -13,7 +13,6 @@ ) from skillcheck.result import Severity - # --------------------------------------------------------------------------- # CritiqueFinding # --------------------------------------------------------------------------- diff --git a/tests/test_description.py b/tests/test_description.py index 8741134..863c50b 100644 --- a/tests/test_description.py +++ b/tests/test_description.py @@ -1,17 +1,17 @@ """Tests for Feature 2: Description quality scoring.""" -import pytest from skillcheck.parser import parse from skillcheck.result import Severity from skillcheck.rules.description import ( + _ACTION_VERBS, + _score_action_verbs, check_description_quality, make_min_score_rule, score_description, ) from tests.conftest import FIXTURES_DIR - # --------------------------------------------------------------------------- # score_description: scoring ranges # --------------------------------------------------------------------------- @@ -187,9 +187,6 @@ def test_third_person_verb_forms_count_via_stem_normalization(): # Issue #2: action-verb allowlist expansion (1.0.2) # --------------------------------------------------------------------------- -from skillcheck.rules.description import _ACTION_VERBS, _score_action_verbs - - def test_newly_added_verbs_all_count_as_action_verbs(): """Every verb added in the #2 expansion (170 total) must register as an action verb when used as the first word of a description. diff --git a/tests/test_disclosure.py b/tests/test_disclosure.py index 20a3fbd..05fd218 100644 --- a/tests/test_disclosure.py +++ b/tests/test_disclosure.py @@ -1,6 +1,5 @@ """Tests for Feature 4: Progressive disclosure budget validation.""" -import pytest from skillcheck.parser import parse from skillcheck.result import Severity @@ -11,7 +10,6 @@ ) from tests.conftest import FIXTURES_DIR - # --------------------------------------------------------------------------- # disclosure.metadata-budget # --------------------------------------------------------------------------- diff --git a/tests/test_explain_score.py b/tests/test_explain_score.py index 6d8274f..db113ab 100644 --- a/tests/test_explain_score.py +++ b/tests/test_explain_score.py @@ -7,7 +7,6 @@ import pytest -from skillcheck.parser import parse as _parse from skillcheck.rules.description import score_description FIXTURES_DIR = Path(__file__).parent / "fixtures" diff --git a/tests/test_fail_on_regression.py b/tests/test_fail_on_regression.py index 58b0684..83c9b8d 100644 --- a/tests/test_fail_on_regression.py +++ b/tests/test_fail_on_regression.py @@ -1,28 +1,22 @@ """Tests for --fail-on-regression: exit code escalation on history.skill.regressed.""" -import json import subprocess import sys from pathlib import Path -import pytest - from skillcheck.core.history import ( + LEDGER_SCHEMA_VERSION, Ledger, LedgerEntry, ResultCounts, RunAgents, ValidationModes, - LEDGER_SCHEMA_VERSION, - append_run, - build_entry, check_regression, ledger_path_for, - load_ledger, save_ledger, ) from skillcheck.parser import parse as _parse -from skillcheck.result import Diagnostic, Severity +from skillcheck.result import Severity FIXTURES_DIR = Path(__file__).parent / "fixtures" GOOD_SKILL = FIXTURES_DIR / "valid_good_desc.md" @@ -199,7 +193,7 @@ def test_flag_unset_regression_warns_exit_0(self): # If the skill passes (no --strict), exit should be 0 even if regression fires result = _run_cli("--history", "--format", "json") # The skill passes validation normally, so exit 0. - # If regression fires, it's a WARNING only — no exit code change. + # If regression fires, it's a WARNING only, no exit code change. assert result.returncode == 0, ( f"Expected exit 0 without --fail-on-regression, got {result.returncode}. " f"stdout: {result.stdout[:200]} stderr: {result.stderr[:200]}" diff --git a/tests/test_format_github.py b/tests/test_format_github.py index 5a003a7..e4407c9 100644 --- a/tests/test_format_github.py +++ b/tests/test_format_github.py @@ -6,9 +6,8 @@ from pathlib import Path from skillcheck.formatters import _format_github, _gha_escape -from skillcheck.result import Diagnostic, ValidationResult, Severity - -from tests.conftest import FIXTURES_DIR +from skillcheck.result import Diagnostic, Severity, ValidationResult +from tests.conftest import FIXTURES_DIR, SKILLCHECK_CMD class TestGhaEscape: @@ -92,7 +91,7 @@ def test_multiple_diagnostics_across_files(self) -> None: def _run(*args: str) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", *args], + [*SKILLCHECK_CMD, *args], capture_output=True, text=True, encoding="utf-8", diff --git a/tests/test_frontmatter.py b/tests/test_frontmatter.py index 907fdfa..6576e14 100644 --- a/tests/test_frontmatter.py +++ b/tests/test_frontmatter.py @@ -1,4 +1,3 @@ -import pytest from skillcheck.parser import parse from skillcheck.result import Severity @@ -9,9 +8,6 @@ check_description_person_voice, check_description_required, check_name_charset, - check_name_consecutive_hyphens, - check_name_directory_match, - check_name_leading_trailing_hyphen, check_name_max_length, check_name_required, check_name_reserved_words, @@ -19,7 +15,6 @@ ) from tests.conftest import FIXTURES_DIR - # --------------------------------------------------------------------------- # name.required # --------------------------------------------------------------------------- diff --git a/tests/test_graph_analyzers.py b/tests/test_graph_analyzers.py index 4de2912..b727964 100644 --- a/tests/test_graph_analyzers.py +++ b/tests/test_graph_analyzers.py @@ -9,8 +9,6 @@ from pathlib import Path -import pytest - from skillcheck.core.graph import CapabilityGraph, extract_graph_heuristic from skillcheck.core.graph_analyzers import ( GRAPH_ANALYZERS, @@ -23,7 +21,6 @@ ) from skillcheck.result import Severity - FIXTURES = Path(__file__).parent / "fixtures" / "graph" diff --git a/tests/test_graph_divergence.py b/tests/test_graph_divergence.py index 71e55fc..9652cb4 100644 --- a/tests/test_graph_divergence.py +++ b/tests/test_graph_divergence.py @@ -4,8 +4,6 @@ from pathlib import Path -import pytest - from skillcheck.agents.graph_parser import parse_graph_response from skillcheck.core.graph import ( Capability, @@ -119,7 +117,6 @@ def test_requires_contradiction_fires_error() -> None: """Agent claims cap -requires-> input where heuristic has both but no edge.""" # Build a custom agent graph where cap "Generate report" requires "DB_URL" # (heuristic has both but no requires edge). - skill = _skill() h = _heuristic() cap = Capability(id="c1", name="Generate report", description="desc", line=8) inp = Input(id="i1", name="DB_URL", kind="env", line=5) diff --git a/tests/test_graph_heuristic.py b/tests/test_graph_heuristic.py index 41f7d39..e40d3e2 100644 --- a/tests/test_graph_heuristic.py +++ b/tests/test_graph_heuristic.py @@ -2,7 +2,6 @@ from __future__ import annotations -import hashlib from pathlib import Path import pytest diff --git a/tests/test_graph_prompts.py b/tests/test_graph_prompts.py index 247fe9f..623ad30 100644 --- a/tests/test_graph_prompts.py +++ b/tests/test_graph_prompts.py @@ -7,7 +7,6 @@ import pytest from skillcheck.agents import GRAPH_AGENTS, get_graph_prompt -from skillcheck.agents.graph_base import GraphExtractionPrompt from skillcheck.agents.graph_claude import ClaudeGraphPrompt from skillcheck.agents.graph_codex import CodexGraphPrompt from skillcheck.agents.graph_cursor import CursorGraphPrompt diff --git a/tests/test_graph_render.py b/tests/test_graph_render.py index 856ef19..59bbe0f 100644 --- a/tests/test_graph_render.py +++ b/tests/test_graph_render.py @@ -15,7 +15,6 @@ from skillcheck.core.graph_render import render_graph_json, render_graph_text from skillcheck.parser import parse as _parse - FIXTURES = Path(__file__).parent / "fixtures" / "graph" diff --git a/tests/test_history_io.py b/tests/test_history_io.py index 31851ea..7b2949d 100644 --- a/tests/test_history_io.py +++ b/tests/test_history_io.py @@ -15,7 +15,6 @@ Ledger, LedgerEntry, LedgerError, - ResultCounts, RunAgents, ValidationModes, append_run, @@ -26,7 +25,6 @@ from skillcheck.parser import ParsedSkill from skillcheck.result import ValidationResult - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -137,7 +135,6 @@ def test_save_writes_valid_json(tmp_path: Path): @pytest.mark.skipif(sys.platform == "win32", reason="chmod restrictions differ on Windows") def test_save_atomic_original_intact_on_failure(tmp_path: Path): """Original ledger survives a failed write (read-only directory).""" - import json skill_path = tmp_path / "SKILL.md" entry = _fixed_entry(skill_path) diff --git a/tests/test_history_model.py b/tests/test_history_model.py index fbe5854..50c2b53 100644 --- a/tests/test_history_model.py +++ b/tests/test_history_model.py @@ -9,21 +9,20 @@ import pytest from skillcheck.core.history import ( - LedgerEntry, Ledger, + LedgerEntry, ResultCounts, RunAgents, ValidationModes, build_entry, compute_skill_hash, ledger_path_for, - save_ledger, load_ledger, + save_ledger, ) from skillcheck.parser import ParsedSkill from skillcheck.result import Diagnostic, Severity, ValidationResult - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/test_history_regression.py b/tests/test_history_regression.py index 63878a5..d9ffdcd 100644 --- a/tests/test_history_regression.py +++ b/tests/test_history_regression.py @@ -16,7 +16,6 @@ from skillcheck.parser import ParsedSkill from skillcheck.result import Severity, ValidationResult - # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- @@ -51,7 +50,6 @@ def _entry(raw: str, valid: bool, ts: str) -> LedgerEntry: entry = build_entry(skill, result, _MODES, _AGENTS, exit_code=0 if valid else 1, version="0.2.0", now=now) # Override the result.valid since build_entry counts diagnostics (all zero here). # Build a new entry with the right valid flag by patching ResultCounts. - from dataclasses import replace # type: ignore[attr-defined] patched_result = ResultCounts( error=0 if valid else 1, warning=entry.result.warning, diff --git a/tests/test_name_compliance.py b/tests/test_name_compliance.py index a3cc954..142ab93 100644 --- a/tests/test_name_compliance.py +++ b/tests/test_name_compliance.py @@ -3,7 +3,6 @@ Covers leading/trailing hyphens, consecutive hyphens, and directory-name matching. """ -import pytest from skillcheck.parser import parse from skillcheck.result import Severity @@ -14,7 +13,6 @@ ) from tests.conftest import FIXTURES_DIR - # --------------------------------------------------------------------------- # name.leading-trailing-hyphen # --------------------------------------------------------------------------- diff --git a/tests/test_parser.py b/tests/test_parser.py index 506c957..35d6ea9 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -1,8 +1,7 @@ -from pathlib import Path import pytest -from skillcheck.parser import ParseError, ParsedSkill, parse +from skillcheck.parser import ParseError, parse from tests.conftest import FIXTURES_DIR diff --git a/tests/test_published_schemas.py b/tests/test_published_schemas.py index 024b74d..14e646e 100644 --- a/tests/test_published_schemas.py +++ b/tests/test_published_schemas.py @@ -15,9 +15,11 @@ from skillcheck.agents import SCHEMAS from skillcheck.agents.graph_parser import ( _TOP_LEVEL_FIELDS as _GRAPH_TOP_LEVEL_FIELDS, +) +from skillcheck.agents.graph_parser import ( + _VALID_EDGE_KINDS, _VALID_INPUT_KINDS, _VALID_OUTPUT_KINDS, - _VALID_EDGE_KINDS, ) from skillcheck.agents.parser import _REQUIRED_FIELDS as _CRITIQUE_REQUIRED_FIELDS from skillcheck.result import Severity diff --git a/tests/test_references.py b/tests/test_references.py index d449638..a988a3c 100644 --- a/tests/test_references.py +++ b/tests/test_references.py @@ -1,16 +1,9 @@ """Tests for Feature 3: File reference validation.""" -import os import sys import pytest -_WINDOWS = sys.platform == "win32" -_skip_symlink = pytest.mark.skipif( - _WINDOWS, - reason="os.symlink requires developer mode or admin privileges on Windows", -) - from skillcheck.parser import parse from skillcheck.result import Severity from skillcheck.rules.references import ( @@ -19,8 +12,12 @@ check_broken_references, check_reference_depth, ) -from tests.conftest import FIXTURES_DIR +_WINDOWS = sys.platform == "win32" +_skip_symlink = pytest.mark.skipif( + _WINDOWS, + reason="os.symlink requires developer mode or admin privileges on Windows", +) # --------------------------------------------------------------------------- # _extract_references diff --git a/tests/test_self_host.py b/tests/test_self_host.py index 88facf1..8344736 100644 --- a/tests/test_self_host.py +++ b/tests/test_self_host.py @@ -12,8 +12,6 @@ import sys from pathlib import Path -import pytest - from skillcheck import validate from skillcheck.core.graph import extract_graph_agent, extract_graph_heuristic from skillcheck.core.graph_analyzers import run_divergence_analyzers, run_graph_analyzers diff --git a/tests/test_semantic_bridge.py b/tests/test_semantic_bridge.py index b24983b..dae1870 100644 --- a/tests/test_semantic_bridge.py +++ b/tests/test_semantic_bridge.py @@ -5,8 +5,6 @@ import json from pathlib import Path -import pytest - from skillcheck.core.semantic import ( INFO_THRESHOLD, WARNING_THRESHOLD, diff --git a/tests/test_sizing.py b/tests/test_sizing.py index bb7ee71..72c3eb0 100644 --- a/tests/test_sizing.py +++ b/tests/test_sizing.py @@ -1,4 +1,3 @@ -import pytest from skillcheck.parser import parse from skillcheck.result import Severity @@ -6,7 +5,6 @@ from skillcheck.tokenizer import estimate_tokens from tests.conftest import FIXTURES_DIR - # --------------------------------------------------------------------------- # sizing.body.line-count # --------------------------------------------------------------------------- diff --git a/tests/test_type_annotations.py b/tests/test_type_annotations.py index c2ec1c2..6ae1742 100644 --- a/tests/test_type_annotations.py +++ b/tests/test_type_annotations.py @@ -2,8 +2,6 @@ from collections.abc import Callable -from skillcheck.parser import ParsedSkill -from skillcheck.result import Diagnostic from skillcheck.rules.compat import make_strict_vscode_rule from skillcheck.rules.description import make_min_score_rule diff --git a/tests/test_v1_2_false_positive_fixes.py b/tests/test_v1_2_false_positive_fixes.py index f92cc33..b0756ad 100644 --- a/tests/test_v1_2_false_positive_fixes.py +++ b/tests/test_v1_2_false_positive_fixes.py @@ -1,7 +1,6 @@ from __future__ import annotations import json -import shutil import subprocess from pathlib import Path @@ -17,7 +16,7 @@ check_name_reserved_words, check_unknown_fields, ) -from tests.conftest import FIXTURES_DIR +from tests.conftest import CLI_AVAILABLE, FIXTURES_DIR, SKILLCHECK_CMD @pytest.fixture(autouse=True) @@ -143,14 +142,11 @@ def test_non_template_placeholder_word_documents_false_positive() -> None: ) -CLI_AVAILABLE = shutil.which("skillcheck") is not None - - @pytest.mark.skipif(not CLI_AVAILABLE, reason="skillcheck command is not installed") def test_claude_api_name_cli_exit_code_zero() -> None: result = subprocess.run( [ - "skillcheck", + *SKILLCHECK_CMD, "--skip-dirname-check", str(FIXTURES_DIR / "claude_api_name.md"), "--format", @@ -171,7 +167,7 @@ def test_claude_api_name_cli_exit_code_zero() -> None: def test_canvas_design_pattern_cli_exit_code_zero() -> None: result = subprocess.run( [ - "skillcheck", + *SKILLCHECK_CMD, "--skip-dirname-check", str(FIXTURES_DIR / "canvas_design_pattern.md"), "--format", diff --git a/tests/test_v1_architecture.py b/tests/test_v1_architecture.py index 529614a..ec74849 100644 --- a/tests/test_v1_architecture.py +++ b/tests/test_v1_architecture.py @@ -3,12 +3,10 @@ import inspect from pathlib import Path -import pytest - from skillcheck import ( Diagnostic, - ParseError, ParsedSkill, + ParseError, Severity, ValidationResult, validate, @@ -16,7 +14,6 @@ from skillcheck.agents.base import SelfCritiquePrompt from skillcheck.core import graph, history, reporter, semantic, symbolic - FIXTURES_DIR = Path(__file__).parent / "fixtures" diff --git a/tests/test_v1_completion.py b/tests/test_v1_completion.py index 16c9682..6d1a375 100644 --- a/tests/test_v1_completion.py +++ b/tests/test_v1_completion.py @@ -9,17 +9,17 @@ import pytest -from tests.conftest import FIXTURES_DIR +from tests.conftest import CLI_AVAILABLE, FIXTURES_DIR, SKILLCHECK_CMD pytestmark = pytest.mark.skipif( - shutil.which("skillcheck") is None, + not CLI_AVAILABLE, reason="skillcheck not installed; run `pip install -e .` first", ) def run(*args: str) -> subprocess.CompletedProcess: return subprocess.run( - ["skillcheck", "--skip-dirname-check", *args], + [*SKILLCHECK_CMD, "--skip-dirname-check", *args], capture_output=True, text=True, encoding="utf-8", @@ -71,7 +71,7 @@ def test_skillcheck_toml_applies_defaults(tmp_path: Path) -> None: encoding="utf-8", ) result = subprocess.run( - ["skillcheck", str(skill)], + [*SKILLCHECK_CMD, str(skill)], capture_output=True, text=True, encoding="utf-8", diff --git a/tests/test_yaml_anchors.py b/tests/test_yaml_anchors.py index 703ca9d..7409390 100644 --- a/tests/test_yaml_anchors.py +++ b/tests/test_yaml_anchors.py @@ -1,6 +1,5 @@ """Tests for Fix 6: YAML anchor/alias detection in frontmatter.""" -import pytest from skillcheck.parser import parse from skillcheck.result import Severity diff --git a/tests/test_yaml_types.py b/tests/test_yaml_types.py index 5ed00d7..8b79b97 100644 --- a/tests/test_yaml_types.py +++ b/tests/test_yaml_types.py @@ -10,13 +10,11 @@ them early with a clear fix (quote the value). """ -import pytest from skillcheck.parser import parse from skillcheck.result import Severity from skillcheck.rules.frontmatter import check_description_type, check_name_type - # --------------------------------------------------------------------------- # frontmatter.name.type # ---------------------------------------------------------------------------