diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c5eeb1..18ce59c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 6eb5c80..32a74f9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 1b0c3c2..0c0cad2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -74,13 +74,16 @@ Example with one deliberate threshold change: ``` Repeated `--scope-exclude ` 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 ` 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 { diff --git a/docs/usage.md b/docs/usage.md index 63357bc..cd604c8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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, diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index 7975fb9..80cb87a 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -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 @@ -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) @@ -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)) @@ -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) @@ -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(): @@ -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": @@ -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: diff --git a/src/agent_code_guard/file_selection.py b/src/agent_code_guard/file_selection.py index be105db..9427c22 100644 --- a/src/agent_code_guard/file_selection.py +++ b/src/agent_code_guard/file_selection.py @@ -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: @@ -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]: diff --git a/tests/test_code_guard.py b/tests/test_code_guard.py index 2d5cc0a..3388afa 100644 --- a/tests/test_code_guard.py +++ b/tests/test_code_guard.py @@ -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": []}, diff --git a/tests/test_scope_counts.py b/tests/test_scope_counts.py new file mode 100644 index 0000000..faaf5de --- /dev/null +++ b/tests/test_scope_counts.py @@ -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()