Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
29 changes: 26 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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
Expand Down
24 changes: 20 additions & 4 deletions src/agent_code_guard/code_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
102 changes: 102 additions & 0 deletions tests/test_json_modes.py
Original file line number Diff line number Diff line change
@@ -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()
Loading