diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ce59c..244ebc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ Notable changes to Agent Code Guard are recorded here. ### Added +- Compact and explicit debug completed-analysis JSON serialization modes while + preserving bare `--json` compatibility. - Deterministic `code-guard --version` reporting from installed distribution metadata, with human and JSON output modes. - Concise selected, analyzed, inapplicable, and all-guard-excluded file counts diff --git a/README.md b/README.md index 32a74f9..dd9a910 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,12 @@ Pull request or branch comparison: code-guard . --base-ref origin/main --ci ``` +Bare `--json` remains the compatible full completed-analysis output. For +routine agent checks, add `--json-mode compact` to omit normalized `pass` +findings while retaining actionable `review` and `fail` findings. Use +`--json-mode debug` as the explicit full-output form when investigating all +measurements. Both named modes require `--json`; no detail mode exists. + The actual base ref must exist or be fetched correctly in the chosen CI environment. diff --git a/docs/usage.md b/docs/usage.md index cd604c8..f19ae36 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -165,9 +165,10 @@ to none of the other sets. All values are non-negative integers. Git-ignored files never discovered, paths outside positional bounds, and absent Git-derived files are not counted as exclusions. -Add `--json` for stable machine-readable output. Completed `PASS`, `REVIEW`, -and `FAIL` results add exactly one top-level summary alongside the existing -`overall`, `requiredPolicies`, and `guards` values: +Add `--json` for the stable, compatible full machine-readable output, including +all passing and actionable findings. Completed `PASS`, `REVIEW`, and `FAIL` +results include exactly one top-level summary alongside the existing `overall`, +`requiredPolicies`, and `guards` values: ```json "scope": { @@ -182,6 +183,28 @@ Counts do not change aggregate state, findings, required policies, or exit codes. Tool errors retain their existing human or JSON error form and do not include a successful `scope` object. +Choose a completed-analysis serialization mode explicitly when needed: + +```bash +code-guard . --json --json-mode compact +code-guard . --json --json-mode debug +``` + +`compact` is intended for routine agent checks. It preserves `overall`, the +complete `scope`, `requiredPolicies`, every guard and guard state, and existing +guard and retained-finding ordering. It omits each finding whose normalized +state is `pass` and retains unchanged findings whose state is `review` or +`fail`. This includes omitting LOC exemptions: their native status is `exempt`, +but their normalized state is `pass`. `debug` is an explicit name for the full +output and is byte-for-byte identical to bare `--json` for the same completed +analysis. + +Both named modes require `--json` and apply only to completed analysis output. +They do not change analysis, scope, policies, ordering, aggregate or guard +states, exit codes, or error shapes and channels. Version JSON supports only +bare `--version --json`; skill-management modes are also incompatible with JSON +analysis options. Values are exact and case-sensitive. There is no detail mode. + `requiredPolicies` lists the policy identifiers needed for actionable findings. An agent should load only those referenced policies, preserve project intent, and decide whether a REVIEW warrants meaningful improvement. A passing result diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index 80cb87a..bea5614 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -52,7 +52,11 @@ def parser() -> argparse.ArgumentParser: value.add_argument("--config", help="Path to code-guard.config.json.") value.add_argument("--warn", type=int, help="Override the global LOC warning threshold.") value.add_argument("--fail", type=int, help="Override the global LOC failure threshold.") - value.add_argument("--json", action="store_true", help="Emit normalized JSON.") + value.add_argument("--json", action="store_true", help="Emit normalized full JSON.") + value.add_argument( + "--json-mode", choices=("compact", "debug"), + help="Completed-analysis JSON mode; requires --json. Compact omits pass findings; debug is full output.", + ) value.add_argument( "--version", action="store_true", help="Print the installed agent-code-guard distribution version; may be combined only with --json.", @@ -99,6 +103,7 @@ def _version_mode(args: argparse.Namespace) -> int | None: or args.ignore_comment_lines or args.skill_path or args.export_skill is not None + or args.json_mode is not None ) if incompatible: raise ValueError("--version may be combined only with --json") @@ -121,6 +126,7 @@ def _management_mode(args: argparse.Namespace) -> int | None: or args.warn is not None or args.fail is not None or args.json + or args.json_mode is not None or args.ci or args.changed_only or args.staged @@ -162,17 +168,25 @@ class CompletedAnalysis: scope: ScopeSummary -def payload(analysis: CompletedAnalysis | list[GuardResult]) -> dict[str, object]: +def payload( + analysis: CompletedAnalysis | list[GuardResult], json_mode: str | None = None, +) -> dict[str, object]: """Serialize a completed run; retain the legacy result-list seam for focused guard tests.""" if isinstance(analysis, list): analysis = CompletedAnalysis(analysis, ScopeSummary(0, 0, 0, 0)) results = analysis.results - return { + data = { "overall": aggregate_state(results), "scope": analysis.scope.to_json(), "requiredPolicies": required_policies(results), "guards": {result.guard_id: result.to_json() for result in results}, } + if json_mode == "compact": + for guard in data["guards"].values(): + guard["findings"] = [ + finding for finding in guard["findings"] if finding["state"] in {"review", "fail"} + ] + return data def run_guards(scope, args: argparse.Namespace) -> list[GuardResult]: @@ -331,6 +345,8 @@ def main() -> int: return _print_tool_error("--version may be combined only with --json", "--json" in raw_arguments) args = parser().parse_args() try: + if args.json_mode is not None and not args.json: + raise ValueError("--json-mode requires --json") version_result = _version_mode(args) if version_result is not None: return version_result @@ -339,7 +355,7 @@ def main() -> int: return management_result validate_configuration(args.config, Path.cwd()) scope = resolve_scope(args, Path.cwd()) - data = payload(run_analysis(scope, args)) + data = payload(run_analysis(scope, args), args.json_mode) if args.json: print(json.dumps(data, indent=2)) else: diff --git a/tests/test_json_modes.py b/tests/test_json_modes.py new file mode 100644 index 0000000..1f1fa74 --- /dev/null +++ b/tests/test_json_modes.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import json +import sys +import unittest +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +from unittest.mock import patch + +from tests.test_cli_version import checkout_code_guard as code_guard + +CompletedAnalysis = code_guard.CompletedAnalysis +ScopeSummary = code_guard.ScopeSummary +CallableFinding = code_guard.callable_size.CallableFinding +Finding = code_guard.loc.Finding +GuardResult = code_guard.GuardResult + + +class JsonModeTests(unittest.TestCase): + def setUp(self) -> None: + self.analysis = CompletedAnalysis( + [ + GuardResult( + "loc", + "pass", + [Finding("allowed.py", "pass", "exempt", 120, 100, 200, 0, "approved")], + ), + GuardResult( + "callableSize", + "review", + [ + CallableFinding("mixed.py", "small", 1, 2, 2, "pass", {"reviewAt": 3}), + CallableFinding("mixed.py", "reviewed", 4, 8, 5, "review", {"reviewAt": 3}), + CallableFinding("mixed.py", "failed", 10, 20, 11, "fail", {"reviewAt": 3}), + ], + ), + ], + ScopeSummary(3, 2, 1, 0), + ) + + def run_main(self, *arguments: str) -> tuple[int, str, str]: + stdout = StringIO() + stderr = StringIO() + with ( + patch.object(sys, "argv", ["code-guard", *arguments]), + patch.object(code_guard, "validate_configuration"), + patch.object(code_guard, "resolve_scope", return_value=object()), + patch.object(code_guard, "run_analysis", return_value=self.analysis), + redirect_stdout(stdout), + redirect_stderr(stderr), + ): + result = code_guard.main() + return result, stdout.getvalue(), stderr.getvalue() + + def test_compact_filters_only_passing_findings_from_a_mixed_result(self) -> None: + full = code_guard.payload(self.analysis) + compact = code_guard.payload(self.analysis, "compact") + + self.assertEqual(compact["overall"], full["overall"]) + self.assertEqual(compact["scope"], full["scope"]) + self.assertEqual(compact["requiredPolicies"], full["requiredPolicies"]) + self.assertEqual(list(compact["guards"]), list(full["guards"])) + self.assertEqual( + {guard: value["state"] for guard, value in compact["guards"].items()}, + {guard: value["state"] for guard, value in full["guards"].items()}, + ) + self.assertEqual(compact["guards"]["loc"]["findings"], []) + self.assertEqual( + compact["guards"]["callableSize"]["findings"], + full["guards"]["callableSize"]["findings"][1:], + ) + + def test_bare_and_debug_completed_json_are_byte_identical(self) -> None: + bare = self.run_main("--json") + debug = self.run_main("--json", "--json-mode", "debug") + + self.assertEqual(debug, bare) + self.assertEqual(bare[0], 1) + self.assertEqual(bare[2], "") + + def test_analysis_mode_cli_compatibility(self) -> None: + cases = ( + ("compact_without_json", ("--json-mode", "compact"), False), + ("debug_without_json", ("--json-mode", "debug"), False), + ("version_compact", ("--version", "--json", "--json-mode", "compact"), True), + ("version_debug", ("--version", "--json", "--json-mode", "debug"), True), + ) + for name, arguments, json_error in cases: + with self.subTest(name=name): + code, stdout, stderr = self.run_main(*arguments) + self.assertEqual(code, 3) + if json_error: + self.assertEqual(stderr, "") + self.assertEqual(set(json.loads(stdout)), {"error"}) + self.assertNotIn("distribution", stdout) + else: + self.assertEqual(stdout, "") + self.assertTrue(stderr.startswith("Code Guard error: ")) + + +if __name__ == "__main__": + unittest.main()