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 @@ -8,3 +8,5 @@ Notable changes to Agent Code Guard are recorded here.

- 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
in every completed human and JSON analysis result.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,25 @@ configuration file is required.

## Result model

Every completed analysis begins with a concise file-scope summary:

```text
PASS: 3 selected; 2 analyzed; 1 inapplicable; 0 excluded.
```

The state is `PASS`, `REVIEW`, or `FAIL`. JSON output adds the same counts as a
top-level `scope` object without changing `overall`, `requiredPolicies`, or
`guards`:

```json
"scope": {
"selected": 3,
"analyzed": 2,
"inapplicable": 1,
"excluded": 0
}
```

- **PASS** — no special action.
- **REVIEW** — inspect the finding and decide whether meaningful improvement is
warranted. REVIEW is not automatic refactoring.
Expand Down
7 changes: 5 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,16 @@ Example with one deliberate threshold change:
```

Repeated `--scope-exclude <glob>` values add caller-supplied all-guard
exclusions and compose with project configuration.
exclusions and compose with project configuration. Only files removed by these
two all-guard forms contribute to the completed result's `excluded` count.

## LOC-specific exclusions

`guards.loc.exclude` applies only to the LOC guard. The repeated CLI option
`--exclude <glob>` adds LOC-only exclusions. These do not remove files from
callable, nesting, complexity, or Markdown analysis.
callable, nesting, complexity, or Markdown analysis, and they do not contribute
to the all-guard `excluded` count. A file skipped only by LOC can still be
`analyzed` by another enabled guard or `inapplicable` when none applies.

```json
{
Expand Down
44 changes: 41 additions & 3 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,47 @@ gamed.

## Human and JSON output

Human output is the default and emphasizes actionable findings. Add `--json`
for stable machine-readable output containing `overall`, per-guard results,
and `requiredPolicies`.
Human output is the default and emphasizes actionable findings. Every completed
analysis starts with the aggregate state and exact scope counts:

```text
PASS: 3 selected; 2 analyzed; 1 inapplicable; 0 excluded.
```

The labels do not pluralize. An empty valid selection is
`PASS: 0 selected; 0 analyzed; 0 inapplicable; 0 excluded.` and exits `0`.
Existing finding and required-policy lines follow this summary unchanged.

The counts have these meanings:

- `selected`: files remaining after discovery, bounds, normalization,
deduplication, absent Git-derived entries, and all-guard exclusions;
- `analyzed`: selected files applicable to at least one enabled guard;
- `inapplicable`: selected files applicable to no enabled guard;
- `excluded`: existing normalized files removed specifically by
`scope.exclude` or `--scope-exclude`.

Therefore `analyzed + inapplicable == selected`, and an excluded file belongs
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:

```json
"scope": {
"selected": 3,
"analyzed": 2,
"inapplicable": 1,
"excluded": 0
}
```

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.

`requiredPolicies` lists the policy identifiers needed for actionable findings.
An agent should load only those referenced policies, preserve project intent,
Expand Down
55 changes: 51 additions & 4 deletions src/agent_code_guard/code_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import argparse
from dataclasses import dataclass
from importlib import import_module
from importlib.metadata import PackageNotFoundError, version as distribution_version
import json
Expand Down Expand Up @@ -139,15 +140,46 @@ def _management_mode(args: argparse.Namespace) -> int | None:
return 0


def payload(results: list[GuardResult]) -> dict[str, object]:
@dataclass(frozen=True)
class ScopeSummary:
selected: int
analyzed: int
inapplicable: int
excluded: int

def to_json(self) -> dict[str, int]:
return {
"selected": self.selected,
"analyzed": self.analyzed,
"inapplicable": self.inapplicable,
"excluded": self.excluded,
}


@dataclass(frozen=True)
class CompletedAnalysis:
results: list[GuardResult]
scope: ScopeSummary


def payload(analysis: CompletedAnalysis | list[GuardResult]) -> 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 {
"overall": aggregate_state(results),
"scope": analysis.scope.to_json(),
"requiredPolicies": required_policies(results),
"guards": {result.guard_id: result.to_json() for result in results},
}


def run_guards(scope, args: argparse.Namespace) -> list[GuardResult]:
return run_analysis(scope, args).results


def run_analysis(scope, args: argparse.Namespace) -> CompletedAnalysis:
"""Load guard configuration, then construct shared syntax facts at most once."""
loc_config = loc.load_config(args)
callable_size_config = callable_size.load_config(args)
Expand All @@ -156,9 +188,14 @@ def run_guards(scope, args: argparse.Namespace) -> list[GuardResult]:
markdown_document_config = markdown_document_size.load_config(args)
markdown_section_config = markdown_section_size.load_config(args)
results = [loc.run(scope.root, loc_config, scope.files)]
analyzed_files = {
path for path in scope.files
if loc_config.enabled and loc.should_include(path, loc_config, scope.root)
}
needs_analysis = callable_size_config.enabled or nesting_config.enabled or complexity_config.enabled
if needs_analysis:
analysis = import_module("agent_code_guard.analysis.pipeline")
analyzed_files.update(path for path in scope.files if analysis.is_applicable(path))
facts = analysis.analyze_files(scope.files)
if callable_size_config.enabled:
results.append(callable_size.run(scope.root, callable_size_config, facts))
Expand All @@ -168,6 +205,7 @@ def run_guards(scope, args: argparse.Namespace) -> list[GuardResult]:
results.append(complexity.run(scope.root, complexity_config, facts))
needs_markdown = markdown_document_config.enabled or markdown_section_config.enabled
markdown_files = tuple(path for path in scope.files if path.suffix.lower() == ".md") if needs_markdown else ()
analyzed_files.update(markdown_files)
if markdown_files:
markdown = import_module("agent_code_guard.markdown")
markdown_facts = markdown.analyze_files(markdown_files)
Expand All @@ -180,7 +218,12 @@ def run_guards(scope, args: argparse.Namespace) -> list[GuardResult]:
results.append(markdown_document_size.run(scope.root, markdown_document_config, _empty_markdown_facts()))
if markdown_section_config.enabled:
results.append(markdown_section_size.run(scope.root, markdown_section_config, _empty_markdown_facts()))
return results
selected = len(scope.files)
analyzed = len(analyzed_files)
return CompletedAnalysis(
results,
ScopeSummary(selected, analyzed, selected - analyzed, len(scope.excluded_files)),
)


def _empty_markdown_facts():
Expand All @@ -190,7 +233,11 @@ def _empty_markdown_facts():


def print_text(data: dict[str, object]) -> None:
print(str(data["overall"]).upper())
scope = data["scope"]
print(
f"{str(data['overall']).upper()}: {scope['selected']} selected; {scope['analyzed']} analyzed; "
f"{scope['inapplicable']} inapplicable; {scope['excluded']} excluded."
)
loc_result = data["guards"]["loc"]
for finding in loc_result["findings"]:
if finding["nativeStatus"] == "ok":
Expand Down Expand Up @@ -292,7 +339,7 @@ def main() -> int:
return management_result
validate_configuration(args.config, Path.cwd())
scope = resolve_scope(args, Path.cwd())
data = payload(run_guards(scope, args))
data = payload(run_analysis(scope, args))
if args.json:
print(json.dumps(data, indent=2))
else:
Expand Down
8 changes: 5 additions & 3 deletions src/agent_code_guard/file_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class SelectionArgs(Protocol):
class ResolvedScope:
root: Path
files: tuple[Path, ...]
excluded_files: tuple[Path, ...] = ()


def find_repo_root(start: Path) -> Path | None:
Expand Down Expand Up @@ -60,11 +61,12 @@ def resolve_scope(args: SelectionArgs, start: Path) -> ResolvedScope:

normalized = tuple(dict.fromkeys(path.resolve() for path in files))
exclusions = load_scope_exclusions(args, working_root)
filtered = tuple(
excluded = tuple(
path for path in normalized
if not any(matches_path_glob(relative_or_absolute_path(path, root), pattern) for pattern in exclusions)
if any(matches_path_glob(relative_or_absolute_path(path, root), pattern) for pattern in exclusions)
)
return ResolvedScope(root, filtered)
excluded_set = set(excluded)
return ResolvedScope(root, tuple(path for path in normalized if path not in excluded_set), excluded)


def resolve_explicit_paths(values: list[str], working_root: Path) -> list[Path]:
Expand Down
5 changes: 4 additions & 1 deletion tests/test_code_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ def test_disabled_loc_has_no_findings_or_policy(self) -> None:
write_lines(root / "large.py", 700)
config = write_config(root, {"enabled": False})
result = self.run_guard(root, ".", "--config", str(config), "--json")
self.assertEqual(self.read_json(result), {"overall": "pass", "requiredPolicies": [], "guards": {
self.assertEqual(self.read_json(result), {
"overall": "pass",
"scope": {"selected": 2, "analyzed": 1, "inapplicable": 1, "excluded": 0},
"requiredPolicies": [], "guards": {
"loc": {"state": "pass", "findings": []},
"callableSize": {"state": "pass", "findings": []},
"nesting": {"state": "pass", "findings": []},
Expand Down
82 changes: 82 additions & 0 deletions tests/test_scope_counts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from __future__ import annotations

import tempfile
from pathlib import Path

from tests.helpers import CodeGuardTestCase, git, init_git, write_config, write_lines


class ScopeCountTests(CodeGuardTestCase):
def test_empty_changed_scope_reports_zero_counts(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
init_git(root)
write_lines(root / "tracked.py", 1)
git(root, "add", ".")
git(root, "commit", "-m", "base")

human = self.run_guard(root, "--changed-only")
json_result = self.run_guard(root, "--changed-only", "--json")

self.assertEqual(human.returncode, 0, human.stderr)
self.assertEqual(
human.stdout.splitlines()[0],
"PASS: 0 selected; 0 analyzed; 0 inapplicable; 0 excluded.",
)
self.assertEqual(json_result.returncode, 0, json_result.stderr)
self.assertEqual(
self.read_json(json_result)["scope"],
{"selected": 0, "analyzed": 0, "inapplicable": 0, "excluded": 0},
)

def test_mixed_scope_reports_analyzed_inapplicable_and_excluded_files(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
write_lines(root / "analyzed.py", 1)
(root / "unsupported.txt").write_text("unsupported\n", encoding="utf-8")
write_lines(root / "excluded.py", 1)
config = write_config(root, {}, scope={"exclude": ["excluded.py"]})

paths = ("analyzed.py", "unsupported.txt", "excluded.py")
human = self.run_guard(root, *paths, "--config", str(config))
json_result = self.run_guard(root, *paths, "--config", str(config), "--json")

self.assertEqual(human.returncode, 0, human.stderr)
self.assertEqual(
human.stdout.splitlines()[0],
"PASS: 2 selected; 1 analyzed; 1 inapplicable; 1 excluded.",
)
self.assertEqual(json_result.returncode, 0, json_result.stderr)
scope = self.read_json(json_result)["scope"]
self.assertEqual(
scope,
{"selected": 2, "analyzed": 1, "inapplicable": 1, "excluded": 1},
)
self.assertEqual(scope["analyzed"] + scope["inapplicable"], scope["selected"])

def test_review_state_and_exit_are_preserved_with_scope_counts(self) -> None:
with tempfile.TemporaryDirectory() as temp:
root = Path(temp)
write_lines(root / "review.py", 2)
config = write_config(root, {"warnAt": 1, "failAt": 3})

human = self.run_guard(root, "review.py", "--config", str(config))
json_result = self.run_guard(root, "review.py", "--config", str(config), "--json")

self.assertEqual(human.returncode, 1, human.stderr)
self.assertEqual(
human.stdout.splitlines()[0],
"REVIEW: 1 selected; 1 analyzed; 0 inapplicable; 0 excluded.",
)
payload = self.read_json(json_result)
self.assertEqual((json_result.returncode, payload["overall"]), (1, "review"))
self.assertEqual(
payload["scope"],
{"selected": 1, "analyzed": 1, "inapplicable": 0, "excluded": 0},
)


if __name__ == "__main__":
import unittest

unittest.main()
Loading