diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a537c3..6e97bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ This project has a published GitHub Release line, but no stable support or API g - Added initial output and exit-code contract documentation for existing `check` and `init` behavior and planned v0.3 `doctor`, `budget`, and `explain` commands. - Added golden output foundation tests for current `check` and `init` console, JSON, Markdown, stdout, stderr, and exit-code behavior. - Added a CLI contract regression matrix for current version, help, `check`, and `init` output channels and exit codes. +- Added the read-only `doctor` baseline command for repository-level instruction diagnosis summaries. ## [0.2.3] - 2026-06-18 diff --git a/docs/EXIT-CODES.md b/docs/EXIT-CODES.md index 419a6a4..3116aba 100644 --- a/docs/EXIT-CODES.md +++ b/docs/EXIT-CODES.md @@ -37,6 +37,21 @@ Notes: - `check` returns `1` only for the supported no-instruction-files state. - `check` returns `2` for repository validation errors raised by discovery and for argparse usage errors. +### `doctor` + +| Condition | Exit code | Stdout | Stderr | +| --- | ---: | --- | --- | +| Supported instruction files found | `0` | Console diagnosis summary | Empty unless lower-level runtime fails unexpectedly | +| No supported instruction files found | `1` | Console no-result diagnosis summary | Empty unless lower-level runtime fails unexpectedly | +| Invalid repository input or command-line usage error | `2` | Empty | Error message or argparse-dependent | + +Notes: + +- `doctor` is read-only. +- `doctor` reuses the existing discovery and governance diagnostics. +- `doctor` findings do not currently make the command fail. +- `doctor` does not audit GitHub branch protection, CI, dependencies, or security certification. + ### `init --dry-run` | Condition | Exit code | Stdout | Stderr | @@ -68,18 +83,6 @@ Notes: The following commands are not implemented yet. Their exit-code contracts are design targets for future implementation phases. -### `doctor` - -Planned direction: - -| Condition | Exit code | -| --- | ---: | -| Repository diagnosis completed with supported instruction files | `0` | -| No supported instruction files found, if aligned with `check` | `1` | -| Invalid repository input or command-line usage error | `2` | - -The implementation phase must decide and test whether no supported instruction files should mirror `check` with `1`. - ### `budget` Planned direction: @@ -114,6 +117,8 @@ The contract regression matrix currently checks: - `check` exits `0` when supported instruction files are found; - `check` exits `1` when no supported instruction files are found; - `check --format json` and `check --format markdown` preserve the same success and no-result exit-code behavior; +- `doctor` exits `0` when supported instruction files are found; +- `doctor` exits `1` when no supported instruction files are found; - `init --dry-run` exits `0`; - `init` without `--dry-run` or `--write` exits `2` and writes the supported error to stderr. diff --git a/docs/OUTPUTS.md b/docs/OUTPUTS.md index d75a6b4..5e4f53a 100644 --- a/docs/OUTPUTS.md +++ b/docs/OUTPUTS.md @@ -11,15 +11,15 @@ Implemented command surface: - `agent-rules-kit --version`; - `agent-rules-kit check`; - `agent-rules-kit init --dry-run`; -- `agent-rules-kit init --write`. +- `agent-rules-kit init --write`; +- `agent-rules-kit doctor`. Planned v0.3 command surface: -- `agent-rules-kit doctor`; - `agent-rules-kit budget`; - `agent-rules-kit explain`. -The planned commands are not implemented yet. Their output contracts are design targets for future phases and must not be documented as available behavior until their implementation phases are merged. +`doctor` is implemented as the first v0.3 command baseline. The remaining planned commands are not implemented yet. Their output contracts are design targets for future phases and must not be documented as available behavior until their implementation phases are merged. ## Contract status @@ -72,7 +72,7 @@ Future behavior should preserve that distinction unless a dedicated phase change | `check --format markdown` | Markdown | yes | Human-readable Markdown report. | | `init --dry-run` | console | yes | Read-only plan; no files modified. | | `init --write` | console | yes | Explicit write mode with backup behavior for existing root `AGENTS.md`. | -| `doctor` | to be defined | no | Planned v0.3 read-only repository summary. | +| `doctor` | console | yes | Read-only repository-level diagnosis summary. | | `budget` | to be defined | no | Planned v0.3 read-only local size/context-pressure approximation. | | `explain` | to be defined | no | Planned v0.3 local rule explanation command. | @@ -200,29 +200,28 @@ Current `init` does not support JSON or Markdown output. `init --dry-run` is read-only. `init --write` is explicit write mode and must remain separate from read-only diagnosis commands. -## Planned v0.3 command contracts - -These commands are design targets. They are not available until their dedicated implementation phases are merged. +## Doctor output contract -### `doctor` +Current `doctor` console output includes: -Planned purpose: +- command header; +- status line; +- supported instruction file count; +- finding count; +- finding counts by severity and rule when findings exist; +- short next-step guidance. -- read-only repository-level diagnosis summary; -- reuse discovery and governance findings; -- summarize supported instruction files, finding counts, and high-level review status. +Current `doctor` exit-code behavior: -Planned output direction: +- `0`: diagnosis completed and supported instruction files were found; +- `1`: no supported instruction files were found; +- `2`: invalid repository input or command-line usage error. -- console summary first; -- JSON only if the implementation phase explicitly defines and tests it; -- no branch protection, CI, dependency, or security certification audit in v0.3. +`doctor` is read-only. It does not audit GitHub branch protection, CI, dependencies, or security certification. -Planned exit-code direction: +## Planned v0.3 command contracts -- `0`: diagnosis completed and supported instruction files were found; -- `1`: no supported instruction files were found, if this mirrors `check`; -- `2`: invalid input or command-line usage error. +The remaining commands are design targets. They are not available until their dedicated implementation phases are merged. ### `budget` diff --git a/src/agent_rules_kit/cli.py b/src/agent_rules_kit/cli.py index 7394a87..ab5583b 100644 --- a/src/agent_rules_kit/cli.py +++ b/src/agent_rules_kit/cli.py @@ -71,6 +71,17 @@ def build_parser() -> argparse.ArgumentParser: help="Write baseline files, backing up existing files first.", ) + doctor_parser = subparsers.add_parser( + "doctor", + help="Summarize repository-level instruction health.", + ) + doctor_parser.add_argument( + "repository", + nargs="?", + default=".", + help="Repository root to inspect. Defaults to the current directory.", + ) + return parser @@ -93,10 +104,89 @@ def main(argv: Sequence[str] | None = None) -> int: write=args.write, ) + if args.command == "doctor": + return _run_doctor(Path(args.repository)) + parser.print_help() return 0 +def _run_doctor(repository_root: Path) -> int: + try: + instruction_files = discover_instruction_files(repository_root) + except ValueError as error: + print(f"ERROR: {redact_secret_like_values(str(error))}", file=sys.stderr) + return 2 + + findings = find_governance_findings(repository_root, instruction_files) + return _print_console_doctor(repository_root, instruction_files, findings) + + +def _print_console_doctor( + repository_root: Path, + instruction_files: tuple[InstructionFile, ...], + findings: tuple[Finding, ...], +) -> int: + print(f"agent-rules-kit doctor: {redact_secret_like_values(str(repository_root))}") + + if not instruction_files: + print("Status: no_instruction_files") + print("Supported instruction files: 0") + print("Findings: 0") + print( + "Next step: add a supported agent instruction file before reviewing " + "governance findings." + ) + return 1 + + status = "review" if findings else "ok" + print(f"Status: {status}") + print(f"Supported instruction files: {len(instruction_files)}") + print(f"Findings: {len(findings)}") + + if findings: + print("Findings by severity:") + for severity, count in _count_findings_by_severity(findings): + print(f"- {severity}: {count}") + + print("Findings by rule:") + for rule_id, count in _count_findings_by_rule(findings): + print(f"- {rule_id}: {count}") + + print("Next step: review the listed governance findings with `agent-rules-kit check`.") + else: + print("Next step: no governance findings were detected by implemented checks.") + + return 0 + + +def _count_findings_by_severity( + findings: tuple[Finding, ...], +) -> tuple[tuple[str, int], ...]: + counts: dict[str, int] = {} + + for finding in findings: + severity = finding.severity.value + counts[severity] = counts.get(severity, 0) + 1 + + return tuple( + (severity, counts[severity]) + for severity in ("info", "warning", "error") + if severity in counts + ) + + +def _count_findings_by_rule( + findings: tuple[Finding, ...], +) -> tuple[tuple[str, int], ...]: + counts: dict[str, int] = {} + + for finding in findings: + counts[finding.rule_id] = counts.get(finding.rule_id, 0) + 1 + + return tuple(counts.items()) + + def _run_check(repository_root: Path, *, output_format: str = "console") -> int: try: instruction_files = discover_instruction_files(repository_root) diff --git a/tests/test_cli.py b/tests/test_cli.py index 19da4a1..8d46b7c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -75,6 +75,64 @@ def test_check_returns_one_when_no_instruction_files_are_found(self) -> None: self.assertEqual(exit_code, 1) self.assertIn("No supported agent instruction files found.", output.getvalue()) + def test_doctor_reports_clean_fixture_summary(self) -> None: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = main(["doctor", str(FIXTURE_ROOT / "single-agent")]) + + text = output.getvalue() + + self.assertEqual(exit_code, 0) + self.assertIn("agent-rules-kit doctor:", text) + self.assertIn("Status: ok", text) + self.assertIn("Supported instruction files: 1", text) + self.assertIn("Findings: 0", text) + self.assertIn( + "Next step: no governance findings were detected by implemented checks.", + text, + ) + + def test_doctor_reports_governance_summary(self) -> None: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = main(["doctor", str(FIXTURE_ROOT / "risky-instructions")]) + + text = output.getvalue() + + self.assertEqual(exit_code, 0) + self.assertIn("Status: review", text) + self.assertIn("Supported instruction files: 1", text) + self.assertIn("Findings: 3", text) + self.assertIn("Findings by severity:", text) + self.assertIn("- warning: 3", text) + self.assertIn("Findings by rule:", text) + self.assertIn("- AIRK-GOV003: 3", text) + self.assertIn("agent-rules-kit check", text) + + def test_doctor_returns_one_when_no_instruction_files_are_found(self) -> None: + output = io.StringIO() + + with redirect_stdout(output): + exit_code = main(["doctor", str(FIXTURE_ROOT / "empty-repo")]) + + text = output.getvalue() + + self.assertEqual(exit_code, 1) + self.assertIn("Status: no_instruction_files", text) + self.assertIn("Supported instruction files: 0", text) + self.assertIn("Findings: 0", text) + + def test_doctor_returns_two_for_invalid_repository_root(self) -> None: + output = io.StringIO() + + with redirect_stderr(output): + exit_code = main(["doctor", str(FIXTURE_ROOT / "missing-repo")]) + + self.assertEqual(exit_code, 2) + self.assertIn("ERROR: repository root does not exist:", output.getvalue()) + def test_check_returns_two_for_invalid_repository_root(self) -> None: output = io.StringIO() diff --git a/tests/test_golden_outputs.py b/tests/test_golden_outputs.py index 1a19b0e..2762192 100644 --- a/tests/test_golden_outputs.py +++ b/tests/test_golden_outputs.py @@ -161,6 +161,22 @@ def test_init_dry_run_existing_agents_file_matches_golden_output(self) -> None: "existing file would be backed up before replacement\n", ) + def test_doctor_clean_fixture_matches_golden_output(self) -> None: + repository = FIXTURE_ROOT / "single-agent" + + result = run_cli(["doctor", str(repository)]) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(result.stderr, "") + self.assertEqual( + result.stdout, + f"agent-rules-kit doctor: {repository}\n" + "Status: ok\n" + "Supported instruction files: 1\n" + "Findings: 0\n" + "Next step: no governance findings were detected by implemented checks.\n", + ) + def test_init_without_mode_matches_golden_error_output(self) -> None: repository = FIXTURE_ROOT / "single-agent" @@ -234,6 +250,20 @@ def test_current_cli_contract_matrix_matches_expected_channels_and_exit_codes(se "stdout_contains": ["# agent-rules-kit check", "- Status: no_instruction_files"], "stderr": "", }, + { + "name": "doctor-clean", + "args": ["doctor", str(FIXTURE_ROOT / "single-agent")], + "exit_code": 0, + "stdout_contains": ["Status: ok", "Supported instruction files: 1"], + "stderr": "", + }, + { + "name": "doctor-empty", + "args": ["doctor", str(FIXTURE_ROOT / "empty-repo")], + "exit_code": 1, + "stdout_contains": ["Status: no_instruction_files", "Findings: 0"], + "stderr": "", + }, { "name": "init-dry-run", "args": ["init", str(FIXTURE_ROOT / "single-agent"), "--dry-run"],