From 14efeac1dbfe5baae8c8936b28bf3b7440777537 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 11:26:35 +0300 Subject: [PATCH 01/10] WIP: establish issue 122 implementation branch (checkpoint) From 4a7b5a74f38640b1dd284f2fc4825a8d8a1d8d40 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 11:34:22 +0300 Subject: [PATCH 02/10] WIP: introduce shared invocation identities and line indexes (checkpoint; tests pending) --- src/agent_code_guard/analysis/facts.py | 13 +++ src/agent_code_guard/analysis/pipeline.py | 17 ++-- src/agent_code_guard/analysis/regions.py | 27 +++--- src/agent_code_guard/code_guard.py | 89 +++++++++++-------- src/agent_code_guard/config_validation.py | 27 +++--- src/agent_code_guard/file_selection.py | 43 ++++++--- src/agent_code_guard/guards/callable_size.py | 27 +++--- src/agent_code_guard/guards/complexity.py | 26 +++--- src/agent_code_guard/guards/loc.py | 51 ++++++----- .../guards/markdown_document_size.py | 27 +++--- .../guards/markdown_section_size.py | 27 +++--- src/agent_code_guard/guards/nesting.py | 26 +++--- src/agent_code_guard/invocation.py | 71 +++++++++++++++ src/agent_code_guard/markdown/facts.py | 1 + src/agent_code_guard/markdown/scanner.py | 26 ++++-- 15 files changed, 312 insertions(+), 186 deletions(-) create mode 100644 src/agent_code_guard/invocation.py diff --git a/src/agent_code_guard/analysis/facts.py b/src/agent_code_guard/analysis/facts.py index a3b66d9..e7e79ee 100644 --- a/src/agent_code_guard/analysis/facts.py +++ b/src/agent_code_guard/analysis/facts.py @@ -82,6 +82,7 @@ class FileFacts: controls: tuple[ControlFlowFact, ...] decisions: tuple[DecisionFact, ...] region_count: int + reporting_path: str | None = None @dataclass(frozen=True) @@ -99,3 +100,15 @@ def controls(self) -> tuple[ControlFlowFact, ...]: @property def decisions(self) -> tuple[DecisionFact, ...]: return tuple(fact for file in self.files for fact in file.decisions) + + def reporting_path_for(self, path: Path, root: Path | None = None) -> str: + stored = next( + (file.reporting_path for file in self.files if file.path == path and file.reporting_path is not None), + None, + ) + if stored is not None: + return stored + try: + return path.relative_to(root).as_posix() if root is not None else path.as_posix() + except ValueError: + return path.as_posix() diff --git a/src/agent_code_guard/analysis/pipeline.py b/src/agent_code_guard/analysis/pipeline.py index 793c6eb..6eb7025 100644 --- a/src/agent_code_guard/analysis/pipeline.py +++ b/src/agent_code_guard/analysis/pipeline.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from pathlib import Path +from ..invocation import SelectedFile + from .adapters import extract_facts from .csharp_compat import corrected_csharp_root from .errors import ProviderUnavailableError, SyntaxAnalysisError @@ -23,6 +25,7 @@ def analyze_files(files: tuple[Path, ...] | list[Path], provider: ParserProvider @dataclass(frozen=True) class UnavailableAnalysis: path: Path + reporting_path: str language: str kind: str message: str @@ -35,27 +38,29 @@ class BatchAnalysis: def analyze_files_for_runner( - files: tuple[Path, ...] | list[Path], provider: ParserProvider | None = None, + files: tuple[SelectedFile, ...] | tuple[Path, ...] | list[Path], provider: ParserProvider | None = None, ) -> BatchAnalysis: """Analyze selected files independently while retaining only known unavailable evidence.""" active_provider = provider or TreeSitterProvider() results: list[FileFacts] = [] unavailable: list[UnavailableAnalysis] = [] for value in files: - path = Path(value) + selected = value if isinstance(value, SelectedFile) else SelectedFile(value.as_posix(), value) + reporting_path = selected.reporting_path if isinstance(value, SelectedFile) else None + path = selected.physical_path if not is_applicable(path): continue try: - results.append(_analyze_file(path, active_provider)) + results.append(_analyze_file(path, active_provider, reporting_path)) except (SyntaxAnalysisError, ProviderUnavailableError) as exc: if exc.language is None: raise kind = "syntax" if isinstance(exc, SyntaxAnalysisError) else "provider" - unavailable.append(UnavailableAnalysis(path, exc.language, kind, str(exc))) + unavailable.append(UnavailableAnalysis(path, selected.reporting_path, exc.language, kind, str(exc))) return BatchAnalysis(AnalysisFacts(tuple(results)), tuple(unavailable)) -def _analyze_file(path: Path, provider: ParserProvider) -> FileFacts: +def _analyze_file(path: Path, provider: ParserProvider, reporting_path: str | None = None) -> FileFacts: callables = [] controls = [] decisions = [] @@ -83,4 +88,4 @@ def _analyze_file(path: Path, provider: ParserProvider) -> FileFacts: callables.extend(region_callables) controls.extend(region_controls) decisions.extend(region_decisions) - return FileFacts(path, tuple(callables), tuple(controls), tuple(decisions), len(regions)) + return FileFacts(path, tuple(callables), tuple(controls), tuple(decisions), len(regions), reporting_path) diff --git a/src/agent_code_guard/analysis/regions.py b/src/agent_code_guard/analysis/regions.py index 23a718e..4b65dcc 100644 --- a/src/agent_code_guard/analysis/regions.py +++ b/src/agent_code_guard/analysis/regions.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from bisect import bisect_right from pathlib import Path from .errors import SyntaxAnalysisError @@ -29,13 +30,20 @@ class ExecutableRegion: source: bytes original_source: bytes original_byte_offset: int = 0 + original_line_starts: tuple[int, ...] | None = None + local_line_starts: tuple[int, ...] | None = None + + def __post_init__(self) -> None: + if self.original_line_starts is None: + object.__setattr__(self, "original_line_starts", _line_starts(self.original_source)) + if self.local_line_starts is None: + object.__setattr__(self, "local_line_starts", _line_starts(self.source)) def original_point(self, local_row: int, local_byte_column: int) -> SourcePoint: - absolute = self.original_byte_offset + _byte_at_point(self.source, local_row, local_byte_column) - prefix = self.original_source[:absolute] - line = prefix.count(b"\n") + 1 - newline = prefix.rfind(b"\n") - byte_column = absolute + 1 if newline < 0 else absolute - newline + absolute = self.original_byte_offset + self.local_line_starts[local_row] + local_byte_column + row = bisect_right(self.original_line_starts, absolute) - 1 + line = row + 1 + byte_column = absolute - self.original_line_starts[row] + 1 return SourcePoint(line, byte_column, absolute) def original_range(self, node) -> SourceRange: @@ -69,6 +77,7 @@ def _vue_regions(path: Path, source: bytes, provider: ParserProvider) -> tuple[E f"unable to parse {path}: Vue container syntax tree contains errors", language="vue", ) regions: list[ExecutableRegion] = [] + original_line_starts = _line_starts(source) for element in root.named_children: if element.type != "script_element": continue @@ -83,15 +92,13 @@ def _vue_regions(path: Path, source: bytes, provider: ParserProvider) -> tuple[E if raw_text is not None: regions.append(ExecutableRegion( path, language, source[raw_text.start_byte:raw_text.end_byte], source, raw_text.start_byte, + original_line_starts, )) return tuple(regions) -def _byte_at_point(source: bytes, row: int, column: int) -> int: - position = 0 - for _ in range(row): - position = source.index(b"\n", position) + 1 - return position + column +def _line_starts(source: bytes) -> tuple[int, ...]: + return (0, *(index + 1 for index, value in enumerate(source) if value == 10)) def _attributes(start_tag, source: bytes) -> dict[str, str | None]: diff --git a/src/agent_code_guard/code_guard.py b/src/agent_code_guard/code_guard.py index 3f3b021..ac604d0 100644 --- a/src/agent_code_guard/code_guard.py +++ b/src/agent_code_guard/code_guard.py @@ -12,12 +12,12 @@ from pathlib import Path from .config_validation import validate_configuration -from .file_selection import resolve_scope +from .file_selection import ResolvedScope, resolve_invocation, resolve_scope from .guards import callable_size, complexity, loc, markdown_document_size, markdown_section_size, nesting from .human_output import format_completed_analysis from . import loc_baseline from .result_model import GuardResult, aggregate_state, required_policies -from .reporting import reporting_path +from .invocation import AnalysisContext, SelectedFile, load_configuration from .skill_distribution import export_skill, skill_path as installed_skill_path DISTRIBUTION_NAME = "agent-code-guard" @@ -329,63 +329,64 @@ def run_guards(scope, args: argparse.Namespace) -> list[GuardResult]: def run_analysis( - scope, args: argparse.Namespace, baseline_override: dict[str, int] | None = None, + scope: AnalysisContext | ResolvedScope, args: argparse.Namespace, baseline_override: dict[str, int] | None = None, baseline_loaded: bool = False, linked_targets: set[Path] | None = None, ) -> CompletedAnalysis: """Load guard configuration, then construct shared syntax facts at most once.""" - loc_config = loc.load_config(args) - baseline = baseline_override if baseline_loaded else loc_baseline.load_if_present(scope.root) + context = scope if isinstance(scope, AnalysisContext) else _legacy_context(scope, args) + loc_config = loc.load_config(args, context.configuration) + baseline = baseline_override if baseline_loaded else loc_baseline.load_if_present(context.root) if baseline is not None: - loc_baseline.validate_paths(scope.root, baseline) + loc_baseline.validate_paths(context.root, baseline) loc_baseline.validate_overlap(baseline, loc_config) - for path in scope.files: - if not path.is_file() or not path.resolve().is_relative_to(scope.root.resolve()): - raise ValueError(f"baseline analysis scope is outside analysis root: {path}") + for selected in context.selected_files: + if not selected.physical_path.is_file() or not selected.physical_path.is_relative_to(context.root): + raise ValueError(f"baseline analysis scope is outside analysis root: {selected.physical_path}") baseline = dict(baseline) for target in linked_targets or set(): - baseline.pop(target.relative_to(scope.root).as_posix(), None) - callable_size_config = callable_size.load_config(args) - nesting_config = nesting.load_config(args) - complexity_config = complexity.load_config(args) - 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, baseline)] + baseline.pop(target.relative_to(context.root).as_posix(), None) + callable_size_config = callable_size.load_config(args, context.configuration) + nesting_config = nesting.load_config(args, context.configuration) + complexity_config = complexity.load_config(args, context.configuration) + markdown_document_config = markdown_document_size.load_config(args, context.configuration) + markdown_section_config = markdown_section_size.load_config(args, context.configuration) + results = [loc.run(context.root, loc_config, context.selected_files, baseline)] analyzed_files = { - path for path in scope.files - if loc_config.enabled and loc.should_include(path, loc_config, scope.root) + selected.reporting_path for selected in context.selected_files + if loc_config.enabled and loc.should_include(selected, loc_config) } 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)) - batch = analysis.analyze_files_for_runner(scope.files) + analyzed_files.update(selected.reporting_path for selected in context.selected_files if analysis.is_applicable(selected.physical_path)) + batch = analysis.analyze_files_for_runner(context.selected_files) facts = batch.facts if callable_size_config.enabled: - results.append(callable_size.run(scope.root, callable_size_config, facts)) + results.append(callable_size.run(context.root, callable_size_config, facts)) if nesting_config.enabled: - results.append(nesting.run(scope.root, nesting_config, facts)) + results.append(nesting.run(context.root, nesting_config, facts)) if complexity_config.enabled: - results.append(complexity.run(scope.root, complexity_config, facts)) + results.append(complexity.run(context.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) + markdown_files = tuple(selected for selected in context.selected_files if selected.physical_path.suffix.lower() == ".md") if needs_markdown else () + analyzed_files.update(selected.reporting_path for selected in markdown_files) if markdown_files: markdown = import_module("agent_code_guard.markdown") markdown_facts = markdown.analyze_files(markdown_files) if markdown_document_config.enabled: - results.append(markdown_document_size.run(scope.root, markdown_document_config, markdown_facts)) + results.append(markdown_document_size.run(context.root, markdown_document_config, markdown_facts)) if markdown_section_config.enabled: - results.append(markdown_section_size.run(scope.root, markdown_section_config, markdown_facts)) + results.append(markdown_section_size.run(context.root, markdown_section_config, markdown_facts)) else: if markdown_document_config.enabled: - results.append(markdown_document_size.run(scope.root, markdown_document_config, _empty_markdown_facts())) + results.append(markdown_document_size.run(context.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())) - selected = len(scope.files) + results.append(markdown_section_size.run(context.root, markdown_section_config, _empty_markdown_facts())) + selected = len(context.selected_files) analyzed = len(analyzed_files) unavailable = tuple( UnavailableEntry( - reporting_path(item.path, scope.root), item.language, item.kind, item.message, + item.reporting_path, item.language, item.kind, item.message, ) for item in (batch.unavailable if needs_analysis else ()) ) @@ -399,7 +400,7 @@ def run_analysis( return CompletedAnalysis( results, ScopeSummary( - selected, analyzed, selected - analyzed, len(scope.excluded_files), + selected, analyzed, selected - analyzed, len(context.excluded_files), len({entry.path for entry in unavailable}) if unavailable else None, ), unavailable, @@ -407,6 +408,21 @@ def run_analysis( ) +def _legacy_context(scope: ResolvedScope, args: argparse.Namespace) -> AnalysisContext: + """Focused-test adapter; the production runner constructs identities during selection.""" + document = validate_configuration(args.config, Path.cwd()) + def selected(path: Path) -> SelectedFile: + try: + report = path.relative_to(scope.root).as_posix() + except ValueError: + report = path.as_posix() + return SelectedFile(report, path) + return AnalysisContext( + scope.root, document, tuple(selected(path) for path in scope.files), + tuple(selected(path) for path in scope.excluded_files), + ) + + def _empty_markdown_facts(): """Avoid importing the scanner family for scopes with no applicable files.""" from types import SimpleNamespace @@ -455,13 +471,16 @@ def main() -> int: management_result = _management_mode(args) if management_result is not None: return management_result - validate_configuration(args.config, Path.cwd()) - scope = resolve_scope(args, Path.cwd()) + invocation = Path.cwd() + configuration = load_configuration(args.config, invocation) + validate_configuration(args.config, invocation, configuration) + scope = resolve_invocation(args, invocation, configuration) linked_targets: set[Path] = set() baseline_loaded = hasattr(scope, "root") if baseline_loaded and loc_baseline.baseline_path(scope.root).exists(): linked_targets = loc_baseline.validate_explicit_scope( - args.paths, Path.cwd(), scope.root, scope.files, + args.paths, invocation, scope.root, + tuple(selected.physical_path for selected in scope.selected_files), ) baseline = loc_baseline.load_if_present(scope.root) if baseline_loaded else None data = payload( diff --git a/src/agent_code_guard/config_validation.py b/src/agent_code_guard/config_validation.py index f01e691..0681231 100644 --- a/src/agent_code_guard/config_validation.py +++ b/src/agent_code_guard/config_validation.py @@ -2,9 +2,10 @@ from __future__ import annotations -import json from pathlib import Path -from typing import Any +from typing import Any, Mapping + +from .invocation import load_configuration ROOT_KEYS = {"version", "scope", "guards"} SCOPE_KEYS = {"exclude"} @@ -40,28 +41,23 @@ LOC_OVERRIDE_KEYS = {"match", "warnAt", "failAt"} -def validate_configuration(config: str | None, start: Path) -> None: - """Load the configured document once and validate its known property names.""" - path = Path(config) if config else start / ".agent-tools" / "code-guard.config.json" - if config and not path.exists(): - raise FileNotFoundError(f"config file not found: {config}") - if not path.exists(): - return - document = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(document, dict): - raise ValueError("configuration must be an object") +def validate_configuration( + config: str | None, start: Path, document: Mapping[str, Any] | None = None, +) -> Mapping[str, Any]: + """Validate known property names in an already-loaded document.""" + document = load_configuration(config, start) if document is None else document _reject_unknown(document, ROOT_KEYS, "") _validate_object_keys(document.get("scope"), SCOPE_KEYS, "scope") guards = document.get("guards") if not isinstance(guards, dict): - return + return document _reject_unknown(guards, GUARD_KEYS, "guards") for guard_name in REVIEW_GUARD_NAMES: _validate_object_keys(guards.get(guard_name), REVIEW_GUARD_KEYS, f"guards.{guard_name}") loc = guards.get("loc") if not isinstance(loc, dict): - return + return document _reject_unknown(loc, LOC_KEYS, "guards.loc") _validate_items( loc.get("allowedLargeFiles"), @@ -69,6 +65,7 @@ def validate_configuration(config: str | None, start: Path) -> None: "guards.loc.allowedLargeFiles", ) _validate_items(loc.get("overrides"), LOC_OVERRIDE_KEYS, "guards.loc.overrides") + return document def _validate_object_keys(value: Any, allowed: set[str], path: str) -> None: @@ -84,7 +81,7 @@ def _validate_items(value: Any, allowed: set[str], path: str) -> None: _reject_unknown(item, allowed, f"{path}[{index}]") -def _reject_unknown(value: dict[str, Any], allowed: set[str], path: str) -> None: +def _reject_unknown(value: Mapping[str, Any], allowed: set[str], path: str) -> None: unknown = sorted(key for key in value if key not in allowed) if unknown: property_path = f"{path}.{unknown[0]}" if path else unknown[0] diff --git a/src/agent_code_guard/file_selection.py b/src/agent_code_guard/file_selection.py index 9427c22..b1424f9 100644 --- a/src/agent_code_guard/file_selection.py +++ b/src/agent_code_guard/file_selection.py @@ -2,13 +2,13 @@ from __future__ import annotations -import json import os import subprocess from dataclasses import dataclass from pathlib import Path from typing import Protocol +from .invocation import AnalysisContext, JsonObject, SelectedFile from .path_matching import matches_path_glob, relative_or_absolute_path BUILTIN_PRUNED_DIRECTORIES = {".git", "node_modules", "bin", "obj"} @@ -30,6 +30,19 @@ class ResolvedScope: excluded_files: tuple[Path, ...] = () +def resolve_invocation( + args: SelectionArgs, start: Path, configuration: JsonObject, +) -> AnalysisContext: + """Resolve every physical/reporting identity once for one invocation.""" + scope = _resolve_scope(args, start, configuration) + return AnalysisContext( + scope.root, + configuration, + tuple(SelectedFile(_reporting_path(path, scope.root), path) for path in scope.files), + tuple(SelectedFile(_reporting_path(path, scope.root), path) for path in scope.excluded_files), + ) + + def find_repo_root(start: Path) -> Path | None: """Return the enclosing Git root, without inventing one when Git is absent.""" try: @@ -44,6 +57,11 @@ def find_repo_root(start: Path) -> Path | None: def resolve_scope(args: SelectionArgs, start: Path) -> ResolvedScope: """Resolve and normalize the complete file scope shared by all guards.""" + from .invocation import load_configuration + return _resolve_scope(args, start, load_configuration(getattr(args, "config", None), start)) + + +def _resolve_scope(args: SelectionArgs, start: Path, configuration: JsonObject) -> ResolvedScope: working_root = start.resolve() git_root = find_repo_root(working_root) root = git_root or working_root @@ -60,15 +78,23 @@ def resolve_scope(args: SelectionArgs, start: Path) -> ResolvedScope: files = existing_files(expand_paths(paths, git_root)) normalized = tuple(dict.fromkeys(path.resolve() for path in files)) - exclusions = load_scope_exclusions(args, working_root) + exclusions = load_scope_exclusions(args, configuration) + identities = tuple((path, _reporting_path(path, root)) for path in normalized) excluded = tuple( - path for path in normalized - if any(matches_path_glob(relative_or_absolute_path(path, root), pattern) for pattern in exclusions) + path for path, reporting_path in identities + if any(matches_path_glob(reporting_path, pattern) for pattern in exclusions) ) excluded_set = set(excluded) return ResolvedScope(root, tuple(path for path in normalized if path not in excluded_set), excluded) +def _reporting_path(canonical_path: Path, canonical_root: Path) -> str: + try: + return canonical_path.relative_to(canonical_root).as_posix() + except ValueError: + return canonical_path.as_posix() + + def resolve_explicit_paths(values: list[str], working_root: Path) -> list[Path]: """Resolve and validate positional paths against the caller's working directory.""" paths = [Path(value) if Path(value).is_absolute() else working_root / value for value in values] @@ -95,14 +121,7 @@ def bound_git_candidates(candidates: list[Path], bounds: list[Path]) -> list[Pat ] -def load_scope_exclusions(args: SelectionArgs, start: Path) -> list[str]: - explicit_config = getattr(args, "config", None) - config_path = Path(explicit_config) if explicit_config else start / ".agent-tools" / "code-guard.config.json" - if explicit_config and not config_path.exists(): - raise FileNotFoundError(f"config file not found: {explicit_config}") - document = json.loads(config_path.read_text(encoding="utf-8")) if config_path.exists() else {} - if not isinstance(document, dict): - raise ValueError("configuration must be an object") +def load_scope_exclusions(args: SelectionArgs, document: JsonObject) -> list[str]: scope = document.get("scope", {}) if not isinstance(scope, dict): raise ValueError("scope must be an object") diff --git a/src/agent_code_guard/guards/callable_size.py b/src/agent_code_guard/guards/callable_size.py index bf10125..ae66e2d 100644 --- a/src/agent_code_guard/guards/callable_size.py +++ b/src/agent_code_guard/guards/callable_size.py @@ -3,12 +3,11 @@ from __future__ import annotations import argparse -import json from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from ..reporting import reporting_path +from ..invocation import JsonObject, configuration_for_guard from ..result_model import CallableFinding, GuardResult if TYPE_CHECKING: @@ -23,17 +22,8 @@ class Config: review_at: int | None = None -def load_config(args: argparse.Namespace) -> Config: - document: dict[str, Any] = {} - if args.config: - path = Path(args.config) - if not path.exists(): - raise FileNotFoundError(f"config file not found: {args.config}") - document = json.loads(path.read_text(encoding="utf-8")) - else: - auto = Path(".agent-tools/code-guard.config.json") - if auto.exists(): - document = json.loads(auto.read_text(encoding="utf-8")) +def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: + document = configuration_for_guard(args, document) if not isinstance(document, dict): raise ValueError("configuration must be an object") guards = document.get("guards", {}) @@ -58,17 +48,20 @@ def load_config(args: argparse.Namespace) -> Config: def run(root: Path, config: Config, analysis_facts: AnalysisFacts) -> GuardResult: if not config.enabled: return GuardResult("callableSize", "pass", []) - findings = [evaluate(root, config, fact) for fact in analysis_facts.callables] + findings = [ + evaluate(root, config, fact, analysis_facts.reporting_path_for(fact.path, root)) + for fact in analysis_facts.callables + ] findings.sort(key=lambda finding: (finding.path, finding.start_line, finding.end_line, finding.callable)) state = "review" if any(finding.state == "review" for finding in findings) else "pass" return GuardResult("callableSize", state, findings) -def evaluate(root: Path, config: Config, fact: CallableFact) -> CallableFinding: +def evaluate(root: Path, config: Config, fact: CallableFact, path: str | None = None) -> CallableFinding: assert config.review_at is not None measured = fact.source_range.physical_loc return CallableFinding( - path=reporting_path(fact.path, root), + path=path or fact.path.as_posix(), callable=fact.identity, start_line=fact.source_range.start_line, end_line=fact.source_range.end_line, diff --git a/src/agent_code_guard/guards/complexity.py b/src/agent_code_guard/guards/complexity.py index d461606..fdaca1a 100644 --- a/src/agent_code_guard/guards/complexity.py +++ b/src/agent_code_guard/guards/complexity.py @@ -4,12 +4,11 @@ import argparse from collections import Counter -import json from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from ..reporting import reporting_path +from ..invocation import JsonObject, configuration_for_guard from ..result_model import CallableFinding, GuardResult if TYPE_CHECKING: @@ -24,17 +23,8 @@ class Config: review_at: int | None = None -def load_config(args: argparse.Namespace) -> Config: - document: dict[str, Any] = {} - if args.config: - path = Path(args.config) - if not path.exists(): - raise FileNotFoundError(f"config file not found: {args.config}") - document = json.loads(path.read_text(encoding="utf-8")) - else: - auto = Path(".agent-tools/code-guard.config.json") - if auto.exists(): - document = json.loads(auto.read_text(encoding="utf-8")) +def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: + document = configuration_for_guard(args, document) if not isinstance(document, dict): raise ValueError("configuration must be an object") guards = document.get("guards", {}) @@ -63,7 +53,10 @@ def run(root: Path, config: Config, analysis_facts: AnalysisFacts) -> GuardResul for decision in analysis_facts.decisions: decisions_by_callable.setdefault(decision.callable_key, []).append(decision) findings = [ - evaluate(root, config, callable_fact, decisions_by_callable.get(callable_fact.key, [])) + evaluate( + root, config, callable_fact, decisions_by_callable.get(callable_fact.key, []), + analysis_facts.reporting_path_for(callable_fact.path, root), + ) for callable_fact in analysis_facts.callables ] findings.sort(key=lambda finding: (finding.path, finding.start_line, finding.end_line, finding.callable)) @@ -76,13 +69,14 @@ def evaluate( config: Config, callable_fact: CallableFact, decisions: list[DecisionFact] | tuple[DecisionFact, ...], + path: str | None = None, ) -> CallableFinding: assert config.review_at is not None counts = Counter(decision.category for decision in decisions) breakdown = {category: counts[category] for category in sorted(counts) if counts[category]} measured = 1 + len(decisions) return CallableFinding( - path=reporting_path(callable_fact.path, root), + path=path or callable_fact.path.as_posix(), callable=callable_fact.identity, start_line=callable_fact.source_range.start_line, end_line=callable_fact.source_range.end_line, diff --git a/src/agent_code_guard/guards/loc.py b/src/agent_code_guard/guards/loc.py index 225b846..9f9aab2 100644 --- a/src/agent_code_guard/guards/loc.py +++ b/src/agent_code_guard/guards/loc.py @@ -3,12 +3,12 @@ from __future__ import annotations import argparse -import json from dataclasses import dataclass from pathlib import Path from typing import Any from ..result_model import Finding, GuardResult +from ..invocation import JsonObject, SelectedFile, configuration_for_guard from ..path_matching import matches_path_glob, relative_or_absolute_path DEFAULT_WARN_AT = 400 @@ -67,18 +67,8 @@ class Config: ratchet_at: str = "fail" -def load_config(args: argparse.Namespace) -> Config: - document: dict[str, Any] = {} - config_path = args.config - if config_path: - path = Path(config_path) - if not path.exists(): - raise FileNotFoundError(f"config file not found: {config_path}") - document = json.loads(path.read_text(encoding="utf-8")) - else: - auto = Path(".agent-tools/code-guard.config.json") - if auto.exists(): - document = json.loads(auto.read_text(encoding="utf-8")) +def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: + document = configuration_for_guard(args, document) if not isinstance(document, dict): raise ValueError("configuration must be an object") guards = document.get("guards", {}) @@ -173,15 +163,16 @@ def normalise_extension(value: str) -> str: def run( - root: Path, config: Config, selected_files: tuple[Path, ...], + root: Path, config: Config, selected_files: tuple[SelectedFile, ...] | tuple[Path, ...], baseline: dict[str, int] | None = None, ) -> GuardResult: if not config.enabled: return GuardResult("loc", "pass", []) - files = [path for path in selected_files if should_include(path, config, root)] + identities = tuple(_selected(value, root) for value in selected_files) + files = [selected for selected in identities if should_include(selected, config)] findings = [ - evaluate(path, config, root, baseline) - for path in sorted(set(files), key=lambda p: relative_path(p, root)) + evaluate(selected, config, baseline) + for selected in sorted(set(files), key=lambda item: item.reporting_path) ] state = "fail" if any(item.state == "fail" for item in findings) else ( "review" if any(item.state == "review" for item in findings) else "pass" @@ -189,17 +180,23 @@ def run( return GuardResult("loc", state, findings) -def should_include(path: Path, config: Config, root: Path) -> bool: - return path.suffix in config.include_extensions and not any( - matches_path_glob(relative_path(path, root), pattern) for pattern in config.exclude +def should_include(selected: SelectedFile | Path, config: Config, root: Path | None = None) -> bool: + selected = _selected(selected, root) + return selected.physical_path.suffix in config.include_extensions and not any( + matches_path_glob(selected.reporting_path, pattern) for pattern in config.exclude ) def evaluate( - path: Path, config: Config, root: Path, baseline: dict[str, int] | None = None, + selected: SelectedFile | Path, config: Config, root_or_baseline=None, + baseline: dict[str, int] | None = None, ) -> Finding: - rel = relative_path(path, root) - counted = count_loc(path, config) + root = root_or_baseline if isinstance(root_or_baseline, Path) else None + if isinstance(root_or_baseline, dict): + baseline = root_or_baseline + selected = _selected(selected, root) + rel = selected.reporting_path + counted = count_loc(selected.physical_path, config) warn_at, fail_at, override_index = effective_thresholds(rel, config) allowed = next((item for item in config.allowed_large_files if matches_path_glob(rel, item.path)), None) baseline_loc = baseline.get(rel) if baseline is not None else None @@ -274,3 +271,11 @@ def effective_thresholds(rel: str, config: Config) -> tuple[int, int, int | None def relative_path(path: Path, root: Path) -> str: return relative_or_absolute_path(path, root) + + +def _selected(value: SelectedFile | Path, root: Path | None) -> SelectedFile: + if isinstance(value, SelectedFile): + return value + if root is None: + raise TypeError("raw paths require an analysis root") + return SelectedFile(relative_path(value, root), value) diff --git a/src/agent_code_guard/guards/markdown_document_size.py b/src/agent_code_guard/guards/markdown_document_size.py index 61ca79d..d7ccc64 100644 --- a/src/agent_code_guard/guards/markdown_document_size.py +++ b/src/agent_code_guard/guards/markdown_document_size.py @@ -3,12 +3,11 @@ from __future__ import annotations import argparse -import json from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from ..reporting import reporting_path +from ..invocation import JsonObject, configuration_for_guard from ..result_model import GuardResult, MarkdownDocumentFinding if TYPE_CHECKING: @@ -23,17 +22,8 @@ class Config: review_at: int | None = None -def load_config(args: argparse.Namespace) -> Config: - document: dict[str, Any] = {} - if args.config: - path = Path(args.config) - if not path.exists(): - raise FileNotFoundError(f"config file not found: {args.config}") - document = json.loads(path.read_text(encoding="utf-8")) - else: - auto = Path(".agent-tools/code-guard.config.json") - if auto.exists(): - document = json.loads(auto.read_text(encoding="utf-8")) +def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: + document = configuration_for_guard(args, document) if not isinstance(document, dict): raise ValueError("configuration must be an object") guards = document.get("guards", {}) @@ -58,9 +48,16 @@ def load_config(args: argparse.Namespace) -> Config: def run(root: Path, config: Config, facts: MarkdownFacts) -> GuardResult: assert config.review_at is not None findings = [MarkdownDocumentFinding( - reporting_path(fact.path, root), fact.physical_lines, + fact.reporting_path or _path(fact.path, root), fact.physical_lines, "review" if fact.physical_lines > config.review_at else "pass", {"reviewAt": config.review_at}, ) for fact in facts.documents] findings.sort(key=lambda finding: finding.path) return GuardResult("markdownDocumentSize", "review" if any(item.state == "review" for item in findings) else "pass", findings) + + +def _path(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() diff --git a/src/agent_code_guard/guards/markdown_section_size.py b/src/agent_code_guard/guards/markdown_section_size.py index f23691f..7968f34 100644 --- a/src/agent_code_guard/guards/markdown_section_size.py +++ b/src/agent_code_guard/guards/markdown_section_size.py @@ -3,12 +3,11 @@ from __future__ import annotations import argparse -import json from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from ..reporting import reporting_path +from ..invocation import JsonObject, configuration_for_guard from ..result_model import GuardResult, MarkdownSectionFinding if TYPE_CHECKING: @@ -23,17 +22,8 @@ class Config: review_at: int | None = None -def load_config(args: argparse.Namespace) -> Config: - document: dict[str, Any] = {} - if args.config: - path = Path(args.config) - if not path.exists(): - raise FileNotFoundError(f"config file not found: {args.config}") - document = json.loads(path.read_text(encoding="utf-8")) - else: - auto = Path(".agent-tools/code-guard.config.json") - if auto.exists(): - document = json.loads(auto.read_text(encoding="utf-8")) +def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: + document = configuration_for_guard(args, document) if not isinstance(document, dict): raise ValueError("configuration must be an object") guards = document.get("guards", {}) @@ -58,9 +48,16 @@ def load_config(args: argparse.Namespace) -> Config: def run(root: Path, config: Config, facts: MarkdownFacts) -> GuardResult: assert config.review_at is not None findings = [MarkdownSectionFinding( - reporting_path(document.path, root), section.heading, section.level, + document.reporting_path or _path(document.path, root), section.heading, section.level, section.start_line, section.end_line, section.physical_lines, "review" if section.physical_lines > config.review_at else "pass", {"reviewAt": config.review_at}, ) for document in facts.documents for section in document.sections] findings.sort(key=lambda finding: (finding.path, finding.start_line, finding.end_line, finding.heading)) return GuardResult("markdownSectionSize", "review" if any(item.state == "review" for item in findings) else "pass", findings) + + +def _path(path: Path, root: Path) -> str: + try: + return path.relative_to(root).as_posix() + except ValueError: + return path.as_posix() diff --git a/src/agent_code_guard/guards/nesting.py b/src/agent_code_guard/guards/nesting.py index 18ae2bf..8a198cb 100644 --- a/src/agent_code_guard/guards/nesting.py +++ b/src/agent_code_guard/guards/nesting.py @@ -3,12 +3,11 @@ from __future__ import annotations import argparse -import json from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING -from ..reporting import reporting_path +from ..invocation import JsonObject, configuration_for_guard from ..result_model import CallableFinding, GuardResult if TYPE_CHECKING: @@ -23,17 +22,8 @@ class Config: review_at: int | None = None -def load_config(args: argparse.Namespace) -> Config: - document: dict[str, Any] = {} - if args.config: - path = Path(args.config) - if not path.exists(): - raise FileNotFoundError(f"config file not found: {args.config}") - document = json.loads(path.read_text(encoding="utf-8")) - else: - auto = Path(".agent-tools/code-guard.config.json") - if auto.exists(): - document = json.loads(auto.read_text(encoding="utf-8")) +def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: + document = configuration_for_guard(args, document) if not isinstance(document, dict): raise ValueError("configuration must be an object") guards = document.get("guards", {}) @@ -62,7 +52,10 @@ def run(root: Path, config: Config, analysis_facts: AnalysisFacts) -> GuardResul for control in analysis_facts.controls: controls_by_callable.setdefault(control.callable_key, []).append(control) findings = [ - evaluate(root, config, callable_fact, controls_by_callable.get(callable_fact.key, [])) + evaluate( + root, config, callable_fact, controls_by_callable.get(callable_fact.key, []), + analysis_facts.reporting_path_for(callable_fact.path, root), + ) for callable_fact in analysis_facts.callables ] findings.sort(key=lambda finding: (finding.path, finding.start_line, finding.end_line, finding.callable)) @@ -75,11 +68,12 @@ def evaluate( config: Config, callable_fact: CallableFact, controls: list[ControlFlowFact] | tuple[ControlFlowFact, ...], + path: str | None = None, ) -> CallableFinding: assert config.review_at is not None depth, deepest_line = _maximum_depth(controls) return CallableFinding( - path=reporting_path(callable_fact.path, root), + path=path or callable_fact.path.as_posix(), callable=callable_fact.identity, start_line=callable_fact.source_range.start_line, end_line=callable_fact.source_range.end_line, diff --git a/src/agent_code_guard/invocation.py b/src/agent_code_guard/invocation.py new file mode 100644 index 0000000..9976f83 --- /dev/null +++ b/src/agent_code_guard/invocation.py @@ -0,0 +1,71 @@ +"""Immutable runner-owned inputs shared by every guard.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping + + +JsonObject = Mapping[str, Any] + + +class FrozenDict(dict): + """A JSON object retaining normal validation compatibility without mutation.""" + + def _immutable(self, *args, **kwargs): + raise TypeError("configuration document is immutable") + + __setitem__ = __delitem__ = clear = pop = popitem = setdefault = update = _immutable + + +class FrozenList(list): + """A JSON array retaining normal validation compatibility without mutation.""" + + def _immutable(self, *args, **kwargs): + raise TypeError("configuration document is immutable") + + __setitem__ = __delitem__ = __iadd__ = __imul__ = append = clear = extend = insert = pop = remove = reverse = sort = _immutable + + +@dataclass(frozen=True, order=True) +class SelectedFile: + """One canonical physical file and its stable public identity.""" + + reporting_path: str + physical_path: Path + + +@dataclass(frozen=True) +class AnalysisContext: + """All immutable inputs established once at the runner boundary.""" + + root: Path + configuration: JsonObject + selected_files: tuple[SelectedFile, ...] + excluded_files: tuple[SelectedFile, ...] = () + + +def load_configuration(config: str | None, start: Path) -> JsonObject: + """Read one configuration document and recursively freeze it.""" + path = Path(config) if config else start / ".agent-tools" / "code-guard.config.json" + if config and not path.exists(): + raise FileNotFoundError(f"config file not found: {config}") + document = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + if not isinstance(document, dict): + raise ValueError("configuration must be an object") + return _freeze(document) + + +def configuration_for_guard(args, document: JsonObject | None) -> JsonObject: + """Compatibility seam for focused guard tests; production supplies the document.""" + return document if document is not None else load_configuration(args.config, Path.cwd()) + + +def _freeze(value: Any) -> Any: + if isinstance(value, dict): + return FrozenDict({key: _freeze(item) for key, item in value.items()}) + if isinstance(value, list): + return FrozenList(_freeze(item) for item in value) + return value diff --git a/src/agent_code_guard/markdown/facts.py b/src/agent_code_guard/markdown/facts.py index d70571a..a368aa9 100644 --- a/src/agent_code_guard/markdown/facts.py +++ b/src/agent_code_guard/markdown/facts.py @@ -20,6 +20,7 @@ class MarkdownDocumentFact: path: Path physical_lines: int sections: tuple[MarkdownSectionFact, ...] + reporting_path: str | None = None @dataclass(frozen=True) diff --git a/src/agent_code_guard/markdown/scanner.py b/src/agent_code_guard/markdown/scanner.py index d479a66..cc4815a 100644 --- a/src/agent_code_guard/markdown/scanner.py +++ b/src/agent_code_guard/markdown/scanner.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from pathlib import Path +from ..invocation import SelectedFile + from .facts import MarkdownDocumentFact, MarkdownFacts, MarkdownSectionFact _ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?)|[ \t]*)$") @@ -28,15 +30,27 @@ class _Fence: length: int -def analyze_files(files: tuple[Path, ...] | list[Path]) -> MarkdownFacts: +def analyze_files(files: tuple[SelectedFile, ...] | tuple[Path, ...] | list[Path]) -> MarkdownFacts: + identities = tuple( + (value, value.reporting_path) if isinstance(value, SelectedFile) + else (SelectedFile(value.as_posix(), value), None) + for value in files + ) applicable = sorted( - {path.resolve() for path in files if path.suffix.lower() == ".md"}, - key=lambda path: path.as_posix(), + ((selected, report) for selected, report in identities if selected.physical_path.suffix.lower() == ".md"), + key=lambda item: item[0].physical_path.as_posix(), ) - return MarkdownFacts(tuple(scan_text(path, path.read_text(encoding="utf-8")) for path in applicable)) + return MarkdownFacts(tuple( + scan_text( + selected.physical_path, + selected.physical_path.read_text(encoding="utf-8"), + report, + ) + for selected, report in applicable + )) -def scan_text(path: Path, text: str) -> MarkdownDocumentFact: +def scan_text(path: Path, text: str, reporting_path: str | None = None) -> MarkdownDocumentFact: lines = text.splitlines() headings = _scan_headings(lines) sections = [] @@ -46,7 +60,7 @@ def scan_text(path: Path, text: str) -> MarkdownDocumentFact: heading.text, heading.level, heading.start_line, end_line, end_line - heading.start_line + 1, )) - return MarkdownDocumentFact(path, len(lines), tuple(sections)) + return MarkdownDocumentFact(path, len(lines), tuple(sections), reporting_path) def _scan_headings(lines: list[str]) -> list[_Heading]: From 2a4dc2bdfe6e6981ccee5891353c8d21531bbd94 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 11:38:20 +0300 Subject: [PATCH 03/10] WIP: add complexity regressions and benchmark harness (checkpoint) --- CHANGELOG.md | 3 + docs/usage.md | 23 ++++++++ src/agent_code_guard/file_selection.py | 11 +++- tests/test_doctor.py | 7 ++- tests/test_shared_invocation.py | 72 ++++++++++++++++++++++ tools/benchmark-wayfarer.ps1 | 82 ++++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 tests/test_shared_invocation.py create mode 100644 tools/benchmark-wayfarer.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc776d0..f709edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Notable changes to Agent Code Guard are recorded here. ## Unreleased +- Reuse one immutable invocation context for configuration and canonical selected-file identities across all guards, and use shared source line indexes for constant-time syntax location mapping. +- Add a reproducible, non-CI Wayfarer benchmark harness for LOC-only, syntax-only, normal, and profiled scans. + ## 0.3.0 - 2026-08-28 ### Added diff --git a/docs/usage.md b/docs/usage.md index 6d4b3d8..688ff3c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -322,3 +322,26 @@ See the [human and agent workflow](agent-workflow.md) for the repeated manual and hook-assisted loop. Installed distributions also carry a version-matched skill payload; [skill distribution](skill-distribution.md) documents discovery, export, and platform activation. +## Performance benchmarking + +Performance changes can be measured against the fixed Wayfarer workload with +`tools/benchmark-wayfarer.ps1`. The caller supplies a disposable checkout at +commit `679ddae9717bf78681a2cfbf794f687127b23b5d`, its exact project config, and +an output directory outside that checkout: + +```powershell +.\tools\benchmark-wayfarer.ps1 ` + -WayfarerPath C:\bench\Wayfarer ` + -ConfigPath C:\bench\wayfarer-code-guard.config.json ` + -OutputDirectory C:\bench\results\after ` + -InstallationMode "editable wheel from issue 122 branch" +``` + +The script validates the source commit, records Python and Code Guard versions, +the configuration hash, exact commands, three fresh sequential warm-process +samples and medians for LOC-only, syntax-only, and normal six-guard scans, plus +a normal-run cProfile file. It compares complete Git status before and after the +run and fails if analysis creates repository metadata. It never clones or writes +to the target checkout and is intentionally not a CI test. Run the same script +and installation mode against the before and after revisions, retaining both +result directories for comparison. diff --git a/src/agent_code_guard/file_selection.py b/src/agent_code_guard/file_selection.py index b1424f9..7dcc40d 100644 --- a/src/agent_code_guard/file_selection.py +++ b/src/agent_code_guard/file_selection.py @@ -50,7 +50,7 @@ def find_repo_root(start: Path) -> Path | None: ["git", "rev-parse", "--show-toplevel"], cwd=start, check=True, text=True, capture_output=True, ) - return Path(result.stdout.strip()).resolve() + return _canonicalize(Path(result.stdout.strip())) except Exception: return None @@ -62,7 +62,7 @@ def resolve_scope(args: SelectionArgs, start: Path) -> ResolvedScope: def _resolve_scope(args: SelectionArgs, start: Path, configuration: JsonObject) -> ResolvedScope: - working_root = start.resolve() + working_root = _canonicalize(start) git_root = find_repo_root(working_root) root = git_root or working_root validate_selection_args(args, git_root) @@ -77,7 +77,7 @@ def _resolve_scope(args: SelectionArgs, start: Path, configuration: JsonObject) else: files = existing_files(expand_paths(paths, git_root)) - normalized = tuple(dict.fromkeys(path.resolve() for path in files)) + normalized = tuple(dict.fromkeys(_canonicalize(path) for path in files)) exclusions = load_scope_exclusions(args, configuration) identities = tuple((path, _reporting_path(path, root)) for path in normalized) excluded = tuple( @@ -109,6 +109,11 @@ def resolve_explicit_paths(values: list[str], working_root: Path) -> list[Path]: return paths +def _canonicalize(path: Path) -> Path: + """Owned filesystem identity seam; never call it from guard loops.""" + return path.resolve() + + def bound_git_candidates(candidates: list[Path], bounds: list[Path]) -> list[Path]: """Intersect Git-selected files with the union of positional file/directory bounds.""" normalized_bounds = [(path.resolve(), path.is_dir()) for path in bounds] diff --git a/tests/test_doctor.py b/tests/test_doctor.py index e9e382c..b80d204 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -169,7 +169,10 @@ def test_reservation_rejection_and_early_dispatch_preserve_analysis_paths(self) self.assertEqual(entry["status"], "unavailable") healthy = {"status": "healthy"} - forbidden = ["validate_configuration", "resolve_scope", "run_analysis", "installed_skill_path", "export_skill"] + forbidden = [ + "load_configuration", "validate_configuration", "resolve_invocation", + "run_analysis", "installed_skill_path", "export_skill", + ] with ExitStack() as stack: mocks = [stack.enter_context(patch.object(code_guard, name)) for name in forbidden] stack.enter_context(patch.object(code_guard, "gather_doctor_report", return_value=healthy)) @@ -177,7 +180,7 @@ def test_reservation_rejection_and_early_dispatch_preserve_analysis_paths(self) for mocked in mocks: mocked.assert_not_called() - with patch.object(code_guard, "resolve_scope", side_effect=RuntimeError("qualified path analyzed")) as resolve: + with patch.object(code_guard, "resolve_invocation", side_effect=RuntimeError("qualified path analyzed")) as resolve: self.assertEqual(self.run_main("./doctor")[0], 3) resolve.assert_called_once() self.assertEqual(self.run_main("doctor", "extra.py")[0], 3) diff --git a/tests/test_shared_invocation.py b/tests/test_shared_invocation.py new file mode 100644 index 0000000..b360f48 --- /dev/null +++ b/tests/test_shared_invocation.py @@ -0,0 +1,72 @@ +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from agent_code_guard.analysis.provider import TreeSitterProvider +from agent_code_guard.analysis.regions import executable_regions +from agent_code_guard.file_selection import resolve_invocation +from agent_code_guard.guards import callable_size, loc +from agent_code_guard.invocation import load_configuration + + +class SharedInvocationTests(unittest.TestCase): + def args(self, patterns): + return SimpleNamespace( + paths=["."], changed_only=False, staged=False, base_ref=None, + config=None, scope_exclude=patterns, include=[], exclude=[], + warn=None, fail=None, count_blank_lines=False, ignore_comment_lines=False, + ) + + def test_candidate_canonicalization_does_not_grow_with_exclusion_patterns(self): + with tempfile.TemporaryDirectory() as value: + root = Path(value) + for name in ("a.py", "b.py", "c.md"): + (root / name).write_text("pass\n", encoding="utf-8") + import agent_code_guard.file_selection as selection + original = selection._canonicalize + counts = [] + with patch.object(selection, "find_repo_root", return_value=None): + for patterns in ([], [f"never-{index}/**" for index in range(50)]): + with patch.object(selection, "_canonicalize", wraps=original) as canonicalize: + context = resolve_invocation(self.args(patterns), root, {}) + self.assertEqual(len(context.selected_files), 3) + counts.append(canonicalize.call_count) + self.assertEqual(counts[0], counts[1]) + + def test_guards_use_loaded_immutable_document_and_selected_identity(self): + with tempfile.TemporaryDirectory() as value: + root = Path(value) + config_path = root / "config.json" + source = root / "sample.py" + config_path.write_text('{"guards":{"loc":{"warnAt":2,"failAt":3}}}', encoding="utf-8") + source.write_text("one\ntwo\n", encoding="utf-8") + document = load_configuration(str(config_path), root) + args = self.args([]) + args.config = str(config_path) + context = resolve_invocation(args, root, document) + with self.assertRaises(TypeError): + document["guards"]["loc"]["warnAt"] = 10 + with patch.object(Path, "read_text", side_effect=AssertionError("configuration reread")): + config = loc.load_config(args, document) + result = loc.run(context.root, config, context.selected_files) + callable_size.load_config(args, document) + self.assertEqual(result.findings[0].path, "sample.py") + + def test_vue_regions_share_one_original_line_index_and_map_utf8_crlf(self): + with tempfile.TemporaryDirectory() as value: + path = Path(value) / "sample.vue" + path.write_bytes(( + "\r\n" + "\r\n" + ).encode("utf-8")) + regions = executable_regions(path, TreeSitterProvider()) + self.assertEqual(len(regions), 2) + self.assertIs(regions[0].original_line_starts, regions[1].original_line_starts) + point = regions[0].original_point(1, len("const label = '".encode("utf-8")) + 2) + self.assertEqual((point.line, point.byte_column), (2, 18)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/benchmark-wayfarer.ps1 b/tools/benchmark-wayfarer.ps1 new file mode 100644 index 0000000..7786264 --- /dev/null +++ b/tools/benchmark-wayfarer.ps1 @@ -0,0 +1,82 @@ +param( + [Parameter(Mandatory = $true)][string]$WayfarerPath, + [Parameter(Mandatory = $true)][string]$ConfigPath, + [Parameter(Mandatory = $true)][string]$OutputDirectory, + [string]$Python = "python", + [string]$InstallationMode = "installed distribution" +) + +$ErrorActionPreference = "Stop" +$expectedCommit = "679ddae9717bf78681a2cfbf794f687127b23b5d" +$target = (Resolve-Path -LiteralPath $WayfarerPath).Path +$config = (Resolve-Path -LiteralPath $ConfigPath).Path +$output = [IO.Path]::GetFullPath($OutputDirectory) +if ($output.StartsWith($target + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { + throw "OutputDirectory must be outside the disposable Wayfarer checkout." +} +$commit = (& git -C $target rev-parse HEAD).Trim() +if ($LASTEXITCODE -ne 0 -or $commit -ne $expectedCommit) { + throw "Wayfarer must be checked out at $expectedCommit; found $commit." +} +$before = (& git -C $target status --porcelain=v1 --untracked-files=all) -join "`n" +New-Item -ItemType Directory -Force -Path $output | Out-Null + +$base = Get-Content -LiteralPath $config -Raw | ConvertFrom-Json -AsHashtable +function New-Variant([string]$name, [hashtable]$enabled) { + $copy = $base | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable + foreach ($guard in @("loc", "callableSize", "nesting", "cyclomaticComplexity", "markdownDocumentSize", "markdownSectionSize")) { + if (-not $copy.guards.ContainsKey($guard)) { $copy.guards[$guard] = @{} } + $copy.guards[$guard].enabled = [bool]$enabled[$guard] + } + $path = Join-Path $output "$name.config.json" + $copy | ConvertTo-Json -Depth 100 | Set-Content -LiteralPath $path -Encoding utf8 + return $path +} +$locOnly = New-Variant "loc-only" @{ loc=$true; callableSize=$false; nesting=$false; cyclomaticComplexity=$false; markdownDocumentSize=$false; markdownSectionSize=$false } +$syntaxOnly = New-Variant "syntax-only" @{ loc=$false; callableSize=$true; nesting=$true; cyclomaticComplexity=$true; markdownDocumentSize=$false; markdownSectionSize=$false } + +$metadata = [ordered]@{ + recordedAtUtc = [DateTime]::UtcNow.ToString("o") + sourceCommit = $commit + configuration = $config + configurationSha256 = (Get-FileHash -LiteralPath $config -Algorithm SHA256).Hash + python = (& $Python --version 2>&1) -join " " + codeGuard = (& $Python -m agent_code_guard.code_guard --version 2>&1) -join " " + installationMode = $InstallationMode + workingDirectory = $target + samples = [ordered]@{} +} +function Measure-Variant([string]$name, [string]$variantConfig) { + $samples = @() + for ($sample = 1; $sample -le 3; $sample++) { + $stdout = Join-Path $output "$name.sample-$sample.json" + $stderr = Join-Path $output "$name.sample-$sample.stderr.txt" + $watch = [Diagnostics.Stopwatch]::StartNew() + $process = Start-Process -FilePath $Python -ArgumentList @( + "-m", "agent_code_guard.code_guard", ".", "--config", $variantConfig, "--json", "--ci" + ) -WorkingDirectory $target -Wait -PassThru -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr + $watch.Stop() + $samples += [ordered]@{ sample=$sample; seconds=$watch.Elapsed.TotalSeconds; exitCode=$process.ExitCode; stdout=$stdout; stderr=$stderr } + } + $ordered = @($samples.seconds | Sort-Object) + $metadata.samples[$name] = [ordered]@{ command="$Python -m agent_code_guard.code_guard . --config `"$variantConfig`" --json --ci"; runs=$samples; medianSeconds=$ordered[1] } +} +Measure-Variant "loc-only" $locOnly +Measure-Variant "syntax-only" $syntaxOnly +Measure-Variant "normal" $config + +$profile = Join-Path $output "normal.cprofile" +$profileStdout = Join-Path $output "normal.profile.json" +$profileStderr = Join-Path $output "normal.profile.stderr.txt" +$profileProcess = Start-Process -FilePath $Python -ArgumentList @( + "-m", "cProfile", "-o", $profile, "-m", "agent_code_guard.code_guard", ".", "--config", $config, "--json", "--ci" +) -WorkingDirectory $target -Wait -PassThru -NoNewWindow -RedirectStandardOutput $profileStdout -RedirectStandardError $profileStderr +$metadata.profile = [ordered]@{ command="$Python -m cProfile -o `"$profile`" -m agent_code_guard.code_guard . --config `"$config`" --json --ci"; exitCode=$profileProcess.ExitCode; output=$profile; stdout=$profileStdout; stderr=$profileStderr } + +$after = (& git -C $target status --porcelain=v1 --untracked-files=all) -join "`n" +$metadata.targetStatusBefore = $before +$metadata.targetStatusAfter = $after +$metadata.normalAnalysisCreatedRepositoryMetadata = ($before -ne $after) +$metadata | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath (Join-Path $output "benchmark-results.json") -Encoding utf8 +if ($before -ne $after) { throw "Benchmark changed the target checkout; inspect benchmark-results.json." } +Write-Output (Join-Path $output "benchmark-results.json") From 40351575ac45c4e1bc36e2290beb53e604f1bedd Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 11:40:53 +0300 Subject: [PATCH 04/10] WIP: use parser byte offsets for source mapping (checkpoint) --- src/agent_code_guard/analysis/adapters.py | 8 ++++---- src/agent_code_guard/analysis/callable_identity.py | 3 +-- src/agent_code_guard/analysis/regions.py | 13 ++++++++----- tests/test_production_analysis.py | 12 ++++++------ 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/agent_code_guard/analysis/adapters.py b/src/agent_code_guard/analysis/adapters.py index 7a967cd..a65ef37 100644 --- a/src/agent_code_guard/analysis/adapters.py +++ b/src/agent_code_guard/analysis/adapters.py @@ -116,11 +116,11 @@ def _range_end_node(node, language: str): def _callable_range(node, region: ExecutableRegion) -> SourceRange: """Snapshot provider points once before mapping them to original source.""" - start_row, start_column = callable_source_start(node, region.language).start_point - end_row, end_column = _range_end_node(node, region.language).end_point + start = callable_source_start(node, region.language) + end = _range_end_node(node, region.language) return SourceRange( - region.original_point(start_row, start_column), - region.original_point(end_row, end_column), + region.original_point_at_byte(start.start_byte), + region.original_point_at_byte(end.end_byte), ) diff --git a/src/agent_code_guard/analysis/callable_identity.py b/src/agent_code_guard/analysis/callable_identity.py index 8eb5eaf..0fcf24e 100644 --- a/src/agent_code_guard/analysis/callable_identity.py +++ b/src/agent_code_guard/analysis/callable_identity.py @@ -272,8 +272,7 @@ def _javascript_lexical_name(node, source: bytes) -> str | None: def _callback_name(node, region: ExecutableRegion) -> str: - row, column = node.start_point - point = region.original_point(row, column) + point = region.original_point_at_byte(node.start_byte) return f"" diff --git a/src/agent_code_guard/analysis/regions.py b/src/agent_code_guard/analysis/regions.py index 4b65dcc..d497cd9 100644 --- a/src/agent_code_guard/analysis/regions.py +++ b/src/agent_code_guard/analysis/regions.py @@ -40,18 +40,21 @@ def __post_init__(self) -> None: object.__setattr__(self, "local_line_starts", _line_starts(self.source)) def original_point(self, local_row: int, local_byte_column: int) -> SourcePoint: - absolute = self.original_byte_offset + self.local_line_starts[local_row] + local_byte_column + local_offset = self.local_line_starts[local_row] + local_byte_column + return self.original_point_at_byte(local_offset) + + def original_point_at_byte(self, local_byte_offset: int) -> SourcePoint: + """Map a parser byte offset without reconstructing its local row prefix.""" + absolute = self.original_byte_offset + local_byte_offset row = bisect_right(self.original_line_starts, absolute) - 1 line = row + 1 byte_column = absolute - self.original_line_starts[row] + 1 return SourcePoint(line, byte_column, absolute) def original_range(self, node) -> SourceRange: - start_row, start_column = node.start_point - end_row, end_column = node.end_point return SourceRange( - self.original_point(start_row, start_column), - self.original_point(end_row, end_column), + self.original_point_at_byte(node.start_byte), + self.original_point_at_byte(node.end_byte), ) diff --git a/tests/test_production_analysis.py b/tests/test_production_analysis.py index 4886d69..3759435 100644 --- a/tests/test_production_analysis.py +++ b/tests/test_production_analysis.py @@ -283,25 +283,25 @@ def test_malformed_ordinary_source_fails_without_partial_facts(self) -> None: class ProviderContractTests(unittest.TestCase): - def test_callable_range_snapshots_each_native_point_once(self) -> None: + def test_callable_range_uses_each_native_byte_offset_once(self) -> None: class Node: start_reads = 0 end_reads = 0 @property - def start_point(self): + def start_byte(self): self.start_reads += 1 - return 2, 3 + return 17 @property - def end_point(self): + def end_byte(self): self.end_reads += 1 - return 4, 5 + return 33 node = Node() region = SimpleNamespace( language="csharp", - original_point=lambda row, column: SourcePoint(row + 1, column + 1, row * 10 + column), + original_point_at_byte=lambda offset: SourcePoint(offset // 7 + 1, offset % 7 + 1, offset), ) source_range = _callable_range(node, region) From 8370afcacf4879b113389b7382b2dccb02e31901 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 11:43:14 +0300 Subject: [PATCH 05/10] Complete issue 122 benchmark workflow --- tools/benchmark-wayfarer.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tools/benchmark-wayfarer.ps1 b/tools/benchmark-wayfarer.ps1 index 7786264..2ac2e67 100644 --- a/tools/benchmark-wayfarer.ps1 +++ b/tools/benchmark-wayfarer.ps1 @@ -48,6 +48,12 @@ $metadata = [ordered]@{ } function Measure-Variant([string]$name, [string]$variantConfig) { $samples = @() + $warmupStdout = Join-Path $output "$name.warmup.json" + $warmupStderr = Join-Path $output "$name.warmup.stderr.txt" + $warmup = Start-Process -FilePath $Python -ArgumentList @( + "-m", "agent_code_guard.code_guard", ".", "--config", $variantConfig, "--json", "--ci" + ) -WorkingDirectory $target -Wait -PassThru -NoNewWindow -RedirectStandardOutput $warmupStdout -RedirectStandardError $warmupStderr + if ($warmup.ExitCode -ne 0) { throw "$name warmup failed with exit $($warmup.ExitCode)." } for ($sample = 1; $sample -le 3; $sample++) { $stdout = Join-Path $output "$name.sample-$sample.json" $stderr = Join-Path $output "$name.sample-$sample.stderr.txt" @@ -59,7 +65,12 @@ function Measure-Variant([string]$name, [string]$variantConfig) { $samples += [ordered]@{ sample=$sample; seconds=$watch.Elapsed.TotalSeconds; exitCode=$process.ExitCode; stdout=$stdout; stderr=$stderr } } $ordered = @($samples.seconds | Sort-Object) - $metadata.samples[$name] = [ordered]@{ command="$Python -m agent_code_guard.code_guard . --config `"$variantConfig`" --json --ci"; runs=$samples; medianSeconds=$ordered[1] } + $metadata.samples[$name] = [ordered]@{ + command="$Python -m agent_code_guard.code_guard . --config `"$variantConfig`" --json --ci" + warmup=[ordered]@{ exitCode=$warmup.ExitCode; stdout=$warmupStdout; stderr=$warmupStderr } + runs=$samples + medianSeconds=$ordered[1] + } } Measure-Variant "loc-only" $locOnly Measure-Variant "syntax-only" $syntaxOnly From 8fa30648cc36959ce095d9e3e6b65544feea328e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 11:47:44 +0300 Subject: [PATCH 06/10] WIP: begin issue 122 review remediation (checkpoint) From 519d0828ac3430bfde25d512229df1f06ec754f4 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 11:52:50 +0300 Subject: [PATCH 07/10] WIP: remediate issue 122 review blockers (checkpoint) --- src/agent_code_guard/analysis/pipeline.py | 15 +++--- src/agent_code_guard/config_validation.py | 13 ++--- src/agent_code_guard/file_selection.py | 5 +- src/agent_code_guard/guards/callable_size.py | 7 +-- src/agent_code_guard/guards/complexity.py | 7 +-- src/agent_code_guard/guards/loc.py | 50 +++++++------------ .../guards/markdown_document_size.py | 7 +-- .../guards/markdown_section_size.py | 7 +-- src/agent_code_guard/guards/nesting.py | 7 +-- src/agent_code_guard/invocation.py | 23 ++------- src/agent_code_guard/loc_baseline.py | 13 +++-- src/agent_code_guard/markdown/scanner.py | 15 ++---- tests/helpers.py | 31 ++++++++++++ tests/test_callable_size.py | 8 +-- tests/test_complexity.py | 2 +- tests/test_dart_named_constructor_identity.py | 3 +- tests/test_go_receiver_callback_identity.py | 3 +- tests/test_markdown_size.py | 7 +-- tests/test_nesting.py | 8 +-- tests/test_packaging.py | 7 ++- tests/test_pattern_guard_complexity.py | 3 +- tests/test_php_complexity.py | 3 +- tests/test_production_analysis.py | 4 +- tests/test_provider_failure_isolation.py | 3 +- tests/test_shared_invocation.py | 2 + tests/test_swift_do_catch.py | 3 +- tests/test_switch_complexity.py | 3 +- tools/benchmark-wayfarer.ps1 | 18 ++++++- 28 files changed, 156 insertions(+), 121 deletions(-) diff --git a/src/agent_code_guard/analysis/pipeline.py b/src/agent_code_guard/analysis/pipeline.py index 6eb7025..7116faa 100644 --- a/src/agent_code_guard/analysis/pipeline.py +++ b/src/agent_code_guard/analysis/pipeline.py @@ -15,10 +15,13 @@ from .regions import executable_regions, is_applicable -def analyze_files(files: tuple[Path, ...] | list[Path], provider: ParserProvider | None = None) -> AnalysisFacts: +def analyze_files(files: tuple[SelectedFile, ...] | list[SelectedFile], provider: ParserProvider | None = None) -> AnalysisFacts: """Analyze only applicable entries from the already-resolved caller scope.""" active_provider = provider or TreeSitterProvider() - results = [_analyze_file(Path(path), active_provider) for path in files if is_applicable(Path(path))] + results = [ + _analyze_file(selected.physical_path, active_provider, selected.reporting_path) + for selected in files if is_applicable(selected.physical_path) + ] return AnalysisFacts(tuple(results)) @@ -38,20 +41,18 @@ class BatchAnalysis: def analyze_files_for_runner( - files: tuple[SelectedFile, ...] | tuple[Path, ...] | list[Path], provider: ParserProvider | None = None, + files: tuple[SelectedFile, ...], provider: ParserProvider | None = None, ) -> BatchAnalysis: """Analyze selected files independently while retaining only known unavailable evidence.""" active_provider = provider or TreeSitterProvider() results: list[FileFacts] = [] unavailable: list[UnavailableAnalysis] = [] - for value in files: - selected = value if isinstance(value, SelectedFile) else SelectedFile(value.as_posix(), value) - reporting_path = selected.reporting_path if isinstance(value, SelectedFile) else None + for selected in files: path = selected.physical_path if not is_applicable(path): continue try: - results.append(_analyze_file(path, active_provider, reporting_path)) + results.append(_analyze_file(path, active_provider, selected.reporting_path)) except (SyntaxAnalysisError, ProviderUnavailableError) as exc: if exc.language is None: raise diff --git a/src/agent_code_guard/config_validation.py b/src/agent_code_guard/config_validation.py index 0681231..f9f63fe 100644 --- a/src/agent_code_guard/config_validation.py +++ b/src/agent_code_guard/config_validation.py @@ -3,7 +3,8 @@ from __future__ import annotations from pathlib import Path -from typing import Any, Mapping +from collections.abc import Mapping, Sequence +from typing import Any from .invocation import load_configuration @@ -50,13 +51,13 @@ def validate_configuration( _validate_object_keys(document.get("scope"), SCOPE_KEYS, "scope") guards = document.get("guards") - if not isinstance(guards, dict): + if not isinstance(guards, Mapping): return document _reject_unknown(guards, GUARD_KEYS, "guards") for guard_name in REVIEW_GUARD_NAMES: _validate_object_keys(guards.get(guard_name), REVIEW_GUARD_KEYS, f"guards.{guard_name}") loc = guards.get("loc") - if not isinstance(loc, dict): + if not isinstance(loc, Mapping): return document _reject_unknown(loc, LOC_KEYS, "guards.loc") _validate_items( @@ -69,15 +70,15 @@ def validate_configuration( def _validate_object_keys(value: Any, allowed: set[str], path: str) -> None: - if isinstance(value, dict): + if isinstance(value, Mapping): _reject_unknown(value, allowed, path) def _validate_items(value: Any, allowed: set[str], path: str) -> None: - if not isinstance(value, list): + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): return for index, item in enumerate(value): - if isinstance(item, dict): + if isinstance(item, Mapping): _reject_unknown(item, allowed, f"{path}[{index}]") diff --git a/src/agent_code_guard/file_selection.py b/src/agent_code_guard/file_selection.py index 7dcc40d..77b4037 100644 --- a/src/agent_code_guard/file_selection.py +++ b/src/agent_code_guard/file_selection.py @@ -4,6 +4,7 @@ import os import subprocess +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path from typing import Protocol @@ -128,10 +129,10 @@ def bound_git_candidates(candidates: list[Path], bounds: list[Path]) -> list[Pat def load_scope_exclusions(args: SelectionArgs, document: JsonObject) -> list[str]: scope = document.get("scope", {}) - if not isinstance(scope, dict): + if not isinstance(scope, Mapping): raise ValueError("scope must be an object") exclude = scope.get("exclude", []) - if not isinstance(exclude, list) or any(not isinstance(pattern, str) for pattern in exclude): + if not isinstance(exclude, Sequence) or isinstance(exclude, (str, bytes)) or any(not isinstance(pattern, str) for pattern in exclude): raise ValueError("scope.exclude must be an array of strings") combined = [*exclude, *getattr(args, "scope_exclude", [])] if any(not isinstance(pattern, str) or not pattern.strip() for pattern in combined): diff --git a/src/agent_code_guard/guards/callable_size.py b/src/agent_code_guard/guards/callable_size.py index ae66e2d..63addaf 100644 --- a/src/agent_code_guard/guards/callable_size.py +++ b/src/agent_code_guard/guards/callable_size.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -24,15 +25,15 @@ class Config: def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: document = configuration_for_guard(args, document) - if not isinstance(document, dict): + if not isinstance(document, Mapping): raise ValueError("configuration must be an object") guards = document.get("guards", {}) - if not isinstance(guards, dict): + if not isinstance(guards, Mapping): raise ValueError("guards must be an object") data = guards.get("callableSize") if data is None: return Config(True, DEFAULT_REVIEW_AT) - if not isinstance(data, dict): + if not isinstance(data, Mapping): raise ValueError("guards.callableSize must be an object") enabled = data.get("enabled", True) if not isinstance(enabled, bool): diff --git a/src/agent_code_guard/guards/complexity.py b/src/agent_code_guard/guards/complexity.py index fdaca1a..425164a 100644 --- a/src/agent_code_guard/guards/complexity.py +++ b/src/agent_code_guard/guards/complexity.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from collections.abc import Mapping from collections import Counter from dataclasses import dataclass from pathlib import Path @@ -25,15 +26,15 @@ class Config: def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: document = configuration_for_guard(args, document) - if not isinstance(document, dict): + if not isinstance(document, Mapping): raise ValueError("configuration must be an object") guards = document.get("guards", {}) - if not isinstance(guards, dict): + if not isinstance(guards, Mapping): raise ValueError("guards must be an object") data = guards.get("cyclomaticComplexity") if data is None: return Config(True, DEFAULT_REVIEW_AT) - if not isinstance(data, dict): + if not isinstance(data, Mapping): raise ValueError("guards.cyclomaticComplexity must be an object") enabled = data.get("enabled", True) if not isinstance(enabled, bool): diff --git a/src/agent_code_guard/guards/loc.py b/src/agent_code_guard/guards/loc.py index 9f9aab2..779fcce 100644 --- a/src/agent_code_guard/guards/loc.py +++ b/src/agent_code_guard/guards/loc.py @@ -3,13 +3,14 @@ from __future__ import annotations import argparse +from collections.abc import Mapping, Sequence from dataclasses import dataclass from pathlib import Path from typing import Any from ..result_model import Finding, GuardResult from ..invocation import JsonObject, SelectedFile, configuration_for_guard -from ..path_matching import matches_path_glob, relative_or_absolute_path +from ..path_matching import matches_path_glob DEFAULT_WARN_AT = 400 DEFAULT_FAIL_AT = 600 @@ -69,13 +70,13 @@ class Config: def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: document = configuration_for_guard(args, document) - if not isinstance(document, dict): + if not isinstance(document, Mapping): raise ValueError("configuration must be an object") guards = document.get("guards", {}) - if not isinstance(guards, dict): + if not isinstance(guards, Mapping): raise ValueError("guards must be an object") data = guards.get("loc", {}) - if not isinstance(data, dict): + if not isinstance(data, Mapping): raise ValueError("guards.loc must be an object") enabled = data.get("enabled", True) if not isinstance(enabled, bool): @@ -91,12 +92,12 @@ def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> if warn_at >= fail_at: raise ValueError("guards.loc.warnAt must be lower than guards.loc.failAt") extensions = data.get("includeExtensions", list(DEFAULT_INCLUDE_EXTENSIONS)) - if not isinstance(extensions, list) or any(not isinstance(value, str) for value in extensions): + if not _is_array(extensions) or any(not isinstance(value, str) for value in extensions): raise ValueError("guards.loc.includeExtensions must be an array of strings") include_extensions = {normalise_extension(value) for value in extensions} include_extensions.update(normalise_extension(value) for value in args.include) exclude = data.get("exclude", DEFAULT_EXCLUDES) - if not isinstance(exclude, list) or any(not isinstance(value, str) for value in exclude): + if not _is_array(exclude) or any(not isinstance(value, str) for value in exclude): raise ValueError("guards.loc.exclude must be an array of strings") exclude = list(exclude) + args.exclude count_blank = data.get("countBlankLines", False) @@ -113,12 +114,12 @@ def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> def parse_allowed_large_files(value: Any) -> list[AllowedLargeFile]: - if not isinstance(value, list): + if not _is_array(value): raise ValueError("guards.loc.allowedLargeFiles must be an array") allowed = [] for index, item in enumerate(value): location = f"guards.loc.allowedLargeFiles[{index}]" - if not isinstance(item, dict): + if not isinstance(item, Mapping): raise ValueError(f"{location} must be an object") path = item.get("path") reason = item.get("reason") @@ -131,15 +132,15 @@ def parse_allowed_large_files(value: Any) -> list[AllowedLargeFile]: def parse_overrides(value: Any) -> list[ThresholdOverride]: - if not isinstance(value, list): + if not _is_array(value): raise ValueError("guards.loc.overrides must be an array") overrides = [] for index, item in enumerate(value): location = f"guards.loc.overrides[{index}]" - if not isinstance(item, dict): + if not isinstance(item, Mapping): raise ValueError(f"{location} must be an object") patterns = item.get("match") - if not isinstance(patterns, list) or not patterns or any( + if not _is_array(patterns) or not patterns or any( not isinstance(pattern, str) or not pattern.strip() for pattern in patterns ): raise ValueError(f"{location}.match must be a non-empty array of non-empty strings") @@ -163,13 +164,12 @@ def normalise_extension(value: str) -> str: def run( - root: Path, config: Config, selected_files: tuple[SelectedFile, ...] | tuple[Path, ...], + root: Path, config: Config, selected_files: tuple[SelectedFile, ...], baseline: dict[str, int] | None = None, ) -> GuardResult: if not config.enabled: return GuardResult("loc", "pass", []) - identities = tuple(_selected(value, root) for value in selected_files) - files = [selected for selected in identities if should_include(selected, config)] + files = [selected for selected in selected_files if should_include(selected, config)] findings = [ evaluate(selected, config, baseline) for selected in sorted(set(files), key=lambda item: item.reporting_path) @@ -180,21 +180,15 @@ def run( return GuardResult("loc", state, findings) -def should_include(selected: SelectedFile | Path, config: Config, root: Path | None = None) -> bool: - selected = _selected(selected, root) +def should_include(selected: SelectedFile, config: Config) -> bool: return selected.physical_path.suffix in config.include_extensions and not any( matches_path_glob(selected.reporting_path, pattern) for pattern in config.exclude ) def evaluate( - selected: SelectedFile | Path, config: Config, root_or_baseline=None, - baseline: dict[str, int] | None = None, + selected: SelectedFile, config: Config, baseline: dict[str, int] | None = None, ) -> Finding: - root = root_or_baseline if isinstance(root_or_baseline, Path) else None - if isinstance(root_or_baseline, dict): - baseline = root_or_baseline - selected = _selected(selected, root) rel = selected.reporting_path counted = count_loc(selected.physical_path, config) warn_at, fail_at, override_index = effective_thresholds(rel, config) @@ -269,13 +263,5 @@ def effective_thresholds(rel: str, config: Config) -> tuple[int, int, int | None return override.warn_at, override.fail_at, index -def relative_path(path: Path, root: Path) -> str: - return relative_or_absolute_path(path, root) - - -def _selected(value: SelectedFile | Path, root: Path | None) -> SelectedFile: - if isinstance(value, SelectedFile): - return value - if root is None: - raise TypeError("raw paths require an analysis root") - return SelectedFile(relative_path(value, root), value) +def _is_array(value: Any) -> bool: + return isinstance(value, Sequence) and not isinstance(value, (str, bytes)) diff --git a/src/agent_code_guard/guards/markdown_document_size.py b/src/agent_code_guard/guards/markdown_document_size.py index d7ccc64..7281f75 100644 --- a/src/agent_code_guard/guards/markdown_document_size.py +++ b/src/agent_code_guard/guards/markdown_document_size.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -24,15 +25,15 @@ class Config: def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: document = configuration_for_guard(args, document) - if not isinstance(document, dict): + if not isinstance(document, Mapping): raise ValueError("configuration must be an object") guards = document.get("guards", {}) - if not isinstance(guards, dict): + if not isinstance(guards, Mapping): raise ValueError("guards must be an object") data = guards.get("markdownDocumentSize") if data is None: return Config(True, DEFAULT_REVIEW_AT) - if not isinstance(data, dict): + if not isinstance(data, Mapping): raise ValueError("guards.markdownDocumentSize must be an object") enabled = data.get("enabled", True) if not isinstance(enabled, bool): diff --git a/src/agent_code_guard/guards/markdown_section_size.py b/src/agent_code_guard/guards/markdown_section_size.py index 7968f34..cc1181e 100644 --- a/src/agent_code_guard/guards/markdown_section_size.py +++ b/src/agent_code_guard/guards/markdown_section_size.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -24,15 +25,15 @@ class Config: def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: document = configuration_for_guard(args, document) - if not isinstance(document, dict): + if not isinstance(document, Mapping): raise ValueError("configuration must be an object") guards = document.get("guards", {}) - if not isinstance(guards, dict): + if not isinstance(guards, Mapping): raise ValueError("guards must be an object") data = guards.get("markdownSectionSize") if data is None: return Config(True, DEFAULT_REVIEW_AT) - if not isinstance(data, dict): + if not isinstance(data, Mapping): raise ValueError("guards.markdownSectionSize must be an object") enabled = data.get("enabled", True) if not isinstance(enabled, bool): diff --git a/src/agent_code_guard/guards/nesting.py b/src/agent_code_guard/guards/nesting.py index 8a198cb..9344605 100644 --- a/src/agent_code_guard/guards/nesting.py +++ b/src/agent_code_guard/guards/nesting.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING @@ -24,15 +25,15 @@ class Config: def load_config(args: argparse.Namespace, document: JsonObject | None = None) -> Config: document = configuration_for_guard(args, document) - if not isinstance(document, dict): + if not isinstance(document, Mapping): raise ValueError("configuration must be an object") guards = document.get("guards", {}) - if not isinstance(guards, dict): + if not isinstance(guards, Mapping): raise ValueError("guards must be an object") data = guards.get("nesting") if data is None: return Config(True, DEFAULT_REVIEW_AT) - if not isinstance(data, dict): + if not isinstance(data, Mapping): raise ValueError("guards.nesting must be an object") enabled = data.get("enabled", True) if not isinstance(enabled, bool): diff --git a/src/agent_code_guard/invocation.py b/src/agent_code_guard/invocation.py index 9976f83..f0907e7 100644 --- a/src/agent_code_guard/invocation.py +++ b/src/agent_code_guard/invocation.py @@ -5,30 +5,13 @@ import json from dataclasses import dataclass from pathlib import Path +from types import MappingProxyType from typing import Any, Mapping JsonObject = Mapping[str, Any] -class FrozenDict(dict): - """A JSON object retaining normal validation compatibility without mutation.""" - - def _immutable(self, *args, **kwargs): - raise TypeError("configuration document is immutable") - - __setitem__ = __delitem__ = clear = pop = popitem = setdefault = update = _immutable - - -class FrozenList(list): - """A JSON array retaining normal validation compatibility without mutation.""" - - def _immutable(self, *args, **kwargs): - raise TypeError("configuration document is immutable") - - __setitem__ = __delitem__ = __iadd__ = __imul__ = append = clear = extend = insert = pop = remove = reverse = sort = _immutable - - @dataclass(frozen=True, order=True) class SelectedFile: """One canonical physical file and its stable public identity.""" @@ -65,7 +48,7 @@ def configuration_for_guard(args, document: JsonObject | None) -> JsonObject: def _freeze(value: Any) -> Any: if isinstance(value, dict): - return FrozenDict({key: _freeze(item) for key, item in value.items()}) + return MappingProxyType({key: _freeze(item) for key, item in value.items()}) if isinstance(value, list): - return FrozenList(_freeze(item) for item in value) + return tuple(_freeze(item) for item in value) return value diff --git a/src/agent_code_guard/loc_baseline.py b/src/agent_code_guard/loc_baseline.py index 3e18d22..12a1ad0 100644 --- a/src/agent_code_guard/loc_baseline.py +++ b/src/agent_code_guard/loc_baseline.py @@ -10,6 +10,7 @@ from .file_selection import is_within from .guards import loc +from .invocation import SelectedFile from .path_matching import matches_path_glob RELATIVE_PATH = ".agent-tools/code-guard.loc-baseline.json" @@ -120,9 +121,10 @@ def create(root: Path, files: tuple[Path, ...], config: loc.Config) -> int: entries: dict[str, int] = {} for path in files: _require_regular_inside(path, root) - if not loc.should_include(path, config, root): + selected = _selected(path, root) + if not loc.should_include(selected, config): continue - relative = loc.relative_path(path, root) + relative = selected.reporting_path if any(matches_path_glob(relative, item.path) for item in config.allowed_large_files): continue counted = loc.count_loc(path, config) @@ -170,7 +172,7 @@ def update( removed += 1 continue _require_regular_inside(path, root) - if path.resolve() in excluded or not loc.should_include(path, config, root): + if path.resolve() in excluded or not loc.should_include(_selected(path, root), config): proposed.pop(relative) removed += 1 continue @@ -195,6 +197,11 @@ def update( return lowered, removed, unchanged +def _selected(path: Path, root: Path) -> SelectedFile: + canonical = path.resolve() + return SelectedFile(canonical.relative_to(root).as_posix(), canonical) + + def serialize(entries: dict[str, int]) -> bytes: document = { "version": 1, diff --git a/src/agent_code_guard/markdown/scanner.py b/src/agent_code_guard/markdown/scanner.py index cc4815a..23003b9 100644 --- a/src/agent_code_guard/markdown/scanner.py +++ b/src/agent_code_guard/markdown/scanner.py @@ -30,23 +30,18 @@ class _Fence: length: int -def analyze_files(files: tuple[SelectedFile, ...] | tuple[Path, ...] | list[Path]) -> MarkdownFacts: - identities = tuple( - (value, value.reporting_path) if isinstance(value, SelectedFile) - else (SelectedFile(value.as_posix(), value), None) - for value in files - ) +def analyze_files(files: tuple[SelectedFile, ...]) -> MarkdownFacts: applicable = sorted( - ((selected, report) for selected, report in identities if selected.physical_path.suffix.lower() == ".md"), - key=lambda item: item[0].physical_path.as_posix(), + (selected for selected in files if selected.physical_path.suffix.lower() == ".md"), + key=lambda selected: selected.reporting_path, ) return MarkdownFacts(tuple( scan_text( selected.physical_path, selected.physical_path.read_text(encoding="utf-8"), - report, + selected.reporting_path, ) - for selected, report in applicable + for selected in applicable )) diff --git a/tests/helpers.py b/tests/helpers.py index 6bd5864..eed2cae 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,11 +1,42 @@ from __future__ import annotations import json +import dataclasses import subprocess import sys import unittest from pathlib import Path +from agent_code_guard.invocation import SelectedFile + + +def selected_files(paths) -> tuple[SelectedFile, ...]: + return tuple(SelectedFile(Path(path).as_posix(), Path(path)) for path in paths) + + +def analyze_source_paths(paths, provider=None): + """Adapt direct test paths without adding a second production API.""" + from agent_code_guard.analysis.pipeline import analyze_files + facts = analyze_files(selected_files(paths), provider) + return dataclasses.replace( + facts, + files=tuple(dataclasses.replace(file, reporting_path=None) for file in facts.files), + ) + + +def analyze_source_paths_for_runner(paths, provider=None): + from agent_code_guard.analysis.pipeline import analyze_files_for_runner + return analyze_files_for_runner(selected_files(paths), provider) + + +def analyze_markdown_paths(paths): + from agent_code_guard.markdown import analyze_files + facts = analyze_files(selected_files(paths)) + return dataclasses.replace( + facts, + documents=tuple(dataclasses.replace(document, reporting_path=None) for document in facts.documents), + ) + REPO_ROOT = Path(__file__).resolve().parents[1] CODE_GUARD = REPO_ROOT / "skills" / "code-guard" / "scripts" / "code_guard.py" diff --git a/tests/test_callable_size.py b/tests/test_callable_size.py index 57887c6..7613ebd 100644 --- a/tests/test_callable_size.py +++ b/tests/test_callable_size.py @@ -8,10 +8,10 @@ from types import SimpleNamespace from unittest.mock import patch -from helpers import CodeGuardTestCase, write_config +from helpers import CodeGuardTestCase, analyze_source_paths as analyze_files, analyze_source_paths_for_runner, write_config +from agent_code_guard.analysis.pipeline import analyze_files_for_runner as production_analyze_files_for_runner -from agent_code_guard.analysis import analyze_files -from agent_code_guard.analysis.pipeline import analyze_files_for_runner +analyze_files_for_runner = analyze_source_paths_for_runner from agent_code_guard.code_guard import run_guards from agent_code_guard.file_selection import ResolvedScope from agent_code_guard.guards import callable_size @@ -156,7 +156,7 @@ def test_disabled_does_not_import_analysis_and_enabled_builds_facts_once(self) - enabled_args = SimpleNamespace(**vars(args(config)), warn=None, fail=None, include=[], exclude=[], count_blank_lines=False, ignore_comment_lines=False) with patch( "agent_code_guard.analysis.pipeline.analyze_files_for_runner", - wraps=analyze_files_for_runner, + wraps=production_analyze_files_for_runner, ) as analyze: results = run_guards(scope, enabled_args) self.assertEqual(analyze.call_count, 1) diff --git a/tests/test_complexity.py b/tests/test_complexity.py index 364cd84..9311664 100644 --- a/tests/test_complexity.py +++ b/tests/test_complexity.py @@ -9,7 +9,7 @@ from types import SimpleNamespace from agent_code_guard.analysis.facts import AnalysisFacts, CallableFact, CallableKey, DecisionFact, FileFacts, SourcePoint, SourceRange -from agent_code_guard.analysis import analyze_files +from helpers import analyze_source_paths as analyze_files from agent_code_guard.code_guard import payload, print_text from agent_code_guard.guards import complexity from agent_code_guard.result_model import GuardResult diff --git a/tests/test_dart_named_constructor_identity.py b/tests/test_dart_named_constructor_identity.py index f6d8c17..6f18766 100644 --- a/tests/test_dart_named_constructor_identity.py +++ b/tests/test_dart_named_constructor_identity.py @@ -5,7 +5,8 @@ import unittest from pathlib import Path -from agent_code_guard.analysis import SyntaxAnalysisError, analyze_files +from agent_code_guard.analysis import SyntaxAnalysisError +from helpers import analyze_source_paths as analyze_files from helpers import CodeGuardTestCase diff --git a/tests/test_go_receiver_callback_identity.py b/tests/test_go_receiver_callback_identity.py index 946faa8..56bda37 100644 --- a/tests/test_go_receiver_callback_identity.py +++ b/tests/test_go_receiver_callback_identity.py @@ -6,7 +6,8 @@ from helpers import CodeGuardTestCase, write_config -from agent_code_guard.analysis import SyntaxAnalysisError, analyze_files +from agent_code_guard.analysis import SyntaxAnalysisError +from helpers import analyze_source_paths as analyze_files def write_source(root: Path, source: str, name: str = "audit.go") -> Path: diff --git a/tests/test_markdown_size.py b/tests/test_markdown_size.py index 450f629..62e5dff 100644 --- a/tests/test_markdown_size.py +++ b/tests/test_markdown_size.py @@ -10,7 +10,8 @@ from agent_code_guard.code_guard import run_guards from agent_code_guard.file_selection import ResolvedScope from agent_code_guard.guards import markdown_document_size, markdown_section_size -from agent_code_guard.markdown import analyze_files, scan_text +from agent_code_guard.markdown import analyze_files as production_analyze_files, scan_text +from helpers import analyze_markdown_paths as analyze_files from helpers import CodeGuardTestCase, write_config, write_lines @@ -205,7 +206,7 @@ def test_markdown_scan_is_independently_lazy_and_shared_once(self) -> None: "callableSize": {"enabled": False}, "nesting": {"enabled": False}, "cyclomaticComplexity": {"enabled": False}, **guards, }) - with self.subTest(guards=guards), patch("agent_code_guard.markdown.analyze_files", wraps=analyze_files) as scan: + with self.subTest(guards=guards), patch("agent_code_guard.markdown.analyze_files", wraps=production_analyze_files) as scan: run_guards(scope, args(config)) self.assertEqual(scan.call_count, expected) @@ -213,7 +214,7 @@ def test_non_markdown_scope_does_not_call_scanner_and_markdown_does_not_call_syn with tempfile.TemporaryDirectory() as temp: root = Path(temp); source = root / "sample.py"; source.write_text("value = 1\n", encoding="utf-8") markdown = root / "notes.md"; markdown.write_text("# Notes\n", encoding="utf-8") - with patch("agent_code_guard.markdown.analyze_files", wraps=analyze_files) as scan: + with patch("agent_code_guard.markdown.analyze_files", wraps=production_analyze_files) as scan: run_guards(ResolvedScope(root, (source,)), args()) self.assertEqual(scan.call_count, 0) config = write_config(root, {"enabled": False}, guards={ diff --git a/tests/test_nesting.py b/tests/test_nesting.py index 2201c3f..ef3133b 100644 --- a/tests/test_nesting.py +++ b/tests/test_nesting.py @@ -8,10 +8,10 @@ from types import SimpleNamespace from unittest.mock import patch -from helpers import CodeGuardTestCase, write_config +from helpers import CodeGuardTestCase, analyze_source_paths as analyze_files, analyze_source_paths_for_runner, write_config +from agent_code_guard.analysis.pipeline import analyze_files_for_runner as production_analyze_files_for_runner -from agent_code_guard.analysis import analyze_files -from agent_code_guard.analysis.pipeline import analyze_files_for_runner +analyze_files_for_runner = analyze_source_paths_for_runner from agent_code_guard.code_guard import run_guards from agent_code_guard.file_selection import ResolvedScope from agent_code_guard.guards import nesting @@ -192,7 +192,7 @@ def test_analysis_activation_matrix_builds_shared_facts_once(self) -> None: config = write_config(root, {"enabled": False}, guards=guards) with self.subTest(guards=guards), patch( "agent_code_guard.analysis.pipeline.analyze_files_for_runner", - wraps=analyze_files_for_runner, + wraps=production_analyze_files_for_runner, ) as analyze: results = run_guards(scope, args(config)) self.assertEqual(analyze.call_count, expected_calls) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 85ca86b..575e0b4 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -170,7 +170,8 @@ def test_installed_syntax_pipeline_loads_parser_dependency(self) -> None: script = ( "import sys; from pathlib import Path; " "from agent_code_guard.analysis.pipeline import analyze_files; " - "facts = analyze_files([Path(sys.argv[1])]); " + "from agent_code_guard.invocation import SelectedFile; " + "path = Path(sys.argv[1]); facts = analyze_files([SelectedFile(path.name, path)]); " "assert [item.identity for item in facts.callables] == " "['Repro.Configure', 'Repro.Sync', 'Repro.Async']" ) @@ -188,7 +189,9 @@ def test_installed_package_exposes_production_markdown_without_research_imports( from pathlib import Path from agent_code_guard.guards import markdown_document_size, markdown_section_size from agent_code_guard.markdown import analyze_files -facts = analyze_files([Path(sys.argv[1])]) +from agent_code_guard.invocation import SelectedFile +path = Path(sys.argv[1]) +facts = analyze_files((SelectedFile(path.name, path),)) print(json.dumps({"lines": facts.documents[0].physical_lines, "research": sorted(name for name in sys.modules if name.startswith("research"))})) """ diff --git a/tests/test_pattern_guard_complexity.py b/tests/test_pattern_guard_complexity.py index cc90c18..c7bc852 100644 --- a/tests/test_pattern_guard_complexity.py +++ b/tests/test_pattern_guard_complexity.py @@ -6,7 +6,8 @@ from helpers import CodeGuardTestCase, write_config -from agent_code_guard.analysis import SyntaxAnalysisError, analyze_files +from agent_code_guard.analysis import SyntaxAnalysisError +from helpers import analyze_source_paths as analyze_files PYTHON_AUDIT = '''def classify(value): diff --git a/tests/test_php_complexity.py b/tests/test_php_complexity.py index 7196aef..cbc73e7 100644 --- a/tests/test_php_complexity.py +++ b/tests/test_php_complexity.py @@ -6,7 +6,8 @@ from helpers import CodeGuardTestCase, write_config -from agent_code_guard.analysis import SyntaxAnalysisError, analyze_files +from agent_code_guard.analysis import SyntaxAnalysisError +from helpers import analyze_source_paths as analyze_files from agent_code_guard.guards import complexity, nesting diff --git a/tests/test_production_analysis.py b/tests/test_production_analysis.py index 3759435..d7531d7 100644 --- a/tests/test_production_analysis.py +++ b/tests/test_production_analysis.py @@ -6,10 +6,10 @@ from pathlib import Path from types import SimpleNamespace -from helpers import REPO_ROOT +from helpers import REPO_ROOT, analyze_source_paths as analyze_files from agent_code_guard.analysis import ( - ProviderUnavailableError, SyntaxAnalysisError, TreeSitterProvider, analyze_files, + ProviderUnavailableError, SyntaxAnalysisError, TreeSitterProvider, ) from agent_code_guard.analysis.adapters import _callable_range from agent_code_guard.analysis.facts import SourcePoint diff --git a/tests/test_provider_failure_isolation.py b/tests/test_provider_failure_isolation.py index b358dd9..3fdb240 100644 --- a/tests/test_provider_failure_isolation.py +++ b/tests/test_provider_failure_isolation.py @@ -4,9 +4,8 @@ import tempfile from pathlib import Path -from tests.helpers import CodeGuardTestCase, write_config +from tests.helpers import CodeGuardTestCase, analyze_source_paths_for_runner as analyze_files_for_runner, write_config -from agent_code_guard.analysis.pipeline import analyze_files_for_runner from agent_code_guard.analysis.provider import TreeSitterProvider diff --git a/tests/test_shared_invocation.py b/tests/test_shared_invocation.py index b360f48..4f81325 100644 --- a/tests/test_shared_invocation.py +++ b/tests/test_shared_invocation.py @@ -48,6 +48,8 @@ def test_guards_use_loaded_immutable_document_and_selected_identity(self): context = resolve_invocation(args, root, document) with self.assertRaises(TypeError): document["guards"]["loc"]["warnAt"] = 10 + with self.assertRaises(TypeError): + document["guards"] |= {"futureGuard": {}} with patch.object(Path, "read_text", side_effect=AssertionError("configuration reread")): config = loc.load_config(args, document) result = loc.run(context.root, config, context.selected_files) diff --git a/tests/test_swift_do_catch.py b/tests/test_swift_do_catch.py index be1778d..0eec279 100644 --- a/tests/test_swift_do_catch.py +++ b/tests/test_swift_do_catch.py @@ -6,7 +6,8 @@ from helpers import CodeGuardTestCase, write_config -from agent_code_guard.analysis import SyntaxAnalysisError, analyze_files +from agent_code_guard.analysis import SyntaxAnalysisError +from helpers import analyze_source_paths as analyze_files PLAIN_AUDIT = '''func plainScopes() { diff --git a/tests/test_switch_complexity.py b/tests/test_switch_complexity.py index 22fae67..887875e 100644 --- a/tests/test_switch_complexity.py +++ b/tests/test_switch_complexity.py @@ -6,7 +6,8 @@ from helpers import CodeGuardTestCase, write_config -from agent_code_guard.analysis import SyntaxAnalysisError, analyze_files +from agent_code_guard.analysis import SyntaxAnalysisError +from helpers import analyze_source_paths as analyze_files CLASSIC_EQUIVALENTS = { diff --git a/tools/benchmark-wayfarer.ps1 b/tools/benchmark-wayfarer.ps1 index 2ac2e67..4eea1e0 100644 --- a/tools/benchmark-wayfarer.ps1 +++ b/tools/benchmark-wayfarer.ps1 @@ -22,10 +22,22 @@ $before = (& git -C $target status --porcelain=v1 --untracked-files=all) -join " New-Item -ItemType Directory -Force -Path $output | Out-Null $base = Get-Content -LiteralPath $config -Raw | ConvertFrom-Json -AsHashtable +$guardNames = @("loc", "callableSize", "nesting", "cyclomaticComplexity", "markdownDocumentSize", "markdownSectionSize") +if ($base -isnot [hashtable] -or $base.guards -isnot [hashtable]) { + throw "Normal benchmark configuration must contain a guards object." +} +$configuredGuardNames = @($base.guards.Keys | Sort-Object) +if (Compare-Object -ReferenceObject @($guardNames | Sort-Object) -DifferenceObject $configuredGuardNames) { + throw "Normal benchmark configuration must contain exactly the six shipped guard sections." +} +foreach ($guard in $guardNames) { + if ($base.guards[$guard] -isnot [hashtable] -or $base.guards[$guard].enabled -ne $true) { + throw "Normal benchmark configuration must explicitly enable guards.$guard." + } +} function New-Variant([string]$name, [hashtable]$enabled) { $copy = $base | ConvertTo-Json -Depth 100 | ConvertFrom-Json -AsHashtable - foreach ($guard in @("loc", "callableSize", "nesting", "cyclomaticComplexity", "markdownDocumentSize", "markdownSectionSize")) { - if (-not $copy.guards.ContainsKey($guard)) { $copy.guards[$guard] = @{} } + foreach ($guard in $guardNames) { $copy.guards[$guard].enabled = [bool]$enabled[$guard] } $path = Join-Path $output "$name.config.json" @@ -62,6 +74,7 @@ function Measure-Variant([string]$name, [string]$variantConfig) { "-m", "agent_code_guard.code_guard", ".", "--config", $variantConfig, "--json", "--ci" ) -WorkingDirectory $target -Wait -PassThru -NoNewWindow -RedirectStandardOutput $stdout -RedirectStandardError $stderr $watch.Stop() + if ($process.ExitCode -ne 0) { throw "$name sample $sample failed with exit $($process.ExitCode)." } $samples += [ordered]@{ sample=$sample; seconds=$watch.Elapsed.TotalSeconds; exitCode=$process.ExitCode; stdout=$stdout; stderr=$stderr } } $ordered = @($samples.seconds | Sort-Object) @@ -82,6 +95,7 @@ $profileStderr = Join-Path $output "normal.profile.stderr.txt" $profileProcess = Start-Process -FilePath $Python -ArgumentList @( "-m", "cProfile", "-o", $profile, "-m", "agent_code_guard.code_guard", ".", "--config", $config, "--json", "--ci" ) -WorkingDirectory $target -Wait -PassThru -NoNewWindow -RedirectStandardOutput $profileStdout -RedirectStandardError $profileStderr +if ($profileProcess.ExitCode -ne 0) { throw "Normal profile failed with exit $($profileProcess.ExitCode)." } $metadata.profile = [ordered]@{ command="$Python -m cProfile -o `"$profile`" -m agent_code_guard.code_guard . --config `"$config`" --json --ci"; exitCode=$profileProcess.ExitCode; output=$profile; stdout=$profileStdout; stderr=$profileStderr } $after = (& git -C $target status --porcelain=v1 --untracked-files=all) -join "`n" From 59303b3db4301e238f458ac9937785f07b8b774d Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 11:54:36 +0300 Subject: [PATCH 08/10] Complete issue 122 review remediation --- research/complexity_sample.py | 5 ++++- research/default_threshold_sample.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/research/complexity_sample.py b/research/complexity_sample.py index eae6c11..af16cf2 100644 --- a/research/complexity_sample.py +++ b/research/complexity_sample.py @@ -12,6 +12,7 @@ from agent_code_guard.analysis import analyze_files, is_applicable from agent_code_guard.file_selection import resolve_scope +from agent_code_guard.invocation import SelectedFile @dataclass(frozen=True) @@ -31,7 +32,9 @@ def measure( """Resolve explicit scope and aggregate existing production facts.""" scope = resolve_scope(_Selection(paths), start) files = tuple(path for path in scope.files if is_applicable(path) and not _excluded(path, scope.root, excludes)) - facts = analyze_files(files) + facts = analyze_files(tuple( + SelectedFile(path.relative_to(scope.root).as_posix(), path) for path in files + )) decisions = defaultdict(Counter) for decision in facts.decisions: if decision.category in excluded_decisions: diff --git a/research/default_threshold_sample.py b/research/default_threshold_sample.py index 1c9586b..3334e55 100644 --- a/research/default_threshold_sample.py +++ b/research/default_threshold_sample.py @@ -10,6 +10,7 @@ from agent_code_guard.analysis import analyze_files, is_applicable from agent_code_guard.file_selection import resolve_scope +from agent_code_guard.invocation import SelectedFile from agent_code_guard.guards.nesting import _maximum_depth from research.complexity_sample import _Selection, _excluded, _nearest_rank @@ -77,7 +78,8 @@ def _metric_summary(rows: list[dict], metric: str, candidates: tuple[int, ...]) def measure(paths: list[str], start: Path, excludes: tuple[str, ...], candidates: dict[str, tuple[int, ...]]) -> dict: scope = resolve_scope(_Selection(paths), start) files = tuple(path for path in scope.files if is_applicable(path) and not _excluded(path, scope.root, excludes)) - result = summarize(analyze_files(files), scope.root, candidates) + selected = tuple(SelectedFile(path.relative_to(scope.root).as_posix(), path) for path in files) + result = summarize(analyze_files(selected), scope.root, candidates) return {"root": str(scope.root), **result} From 85c3e5987badb1455f7744f337c052f0a733ccee Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 12:19:14 +0300 Subject: [PATCH 09/10] WIP: begin reporting identity lookup remediation (checkpoint) From e59393fdc1b8966caf0277241a34f0dd917498d2 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sat, 29 Aug 2026 12:19:51 +0300 Subject: [PATCH 10/10] Optimize shared reporting identity lookup --- src/agent_code_guard/analysis/facts.py | 17 ++++++++++++----- tests/test_shared_invocation.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/agent_code_guard/analysis/facts.py b/src/agent_code_guard/analysis/facts.py index e7e79ee..21a63c7 100644 --- a/src/agent_code_guard/analysis/facts.py +++ b/src/agent_code_guard/analysis/facts.py @@ -2,8 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from types import MappingProxyType +from typing import Mapping @dataclass(frozen=True, order=True) @@ -88,6 +90,14 @@ class FileFacts: @dataclass(frozen=True) class AnalysisFacts: files: tuple[FileFacts, ...] + _reporting_paths: Mapping[Path, str] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "_reporting_paths", MappingProxyType({ + file.path: file.reporting_path + for file in self.files + if file.reporting_path is not None + })) @property def callables(self) -> tuple[CallableFact, ...]: @@ -102,10 +112,7 @@ def decisions(self) -> tuple[DecisionFact, ...]: return tuple(fact for file in self.files for fact in file.decisions) def reporting_path_for(self, path: Path, root: Path | None = None) -> str: - stored = next( - (file.reporting_path for file in self.files if file.path == path and file.reporting_path is not None), - None, - ) + stored = self._reporting_paths.get(path) if stored is not None: return stored try: diff --git a/tests/test_shared_invocation.py b/tests/test_shared_invocation.py index 4f81325..e515540 100644 --- a/tests/test_shared_invocation.py +++ b/tests/test_shared_invocation.py @@ -6,6 +6,7 @@ from agent_code_guard.analysis.provider import TreeSitterProvider from agent_code_guard.analysis.regions import executable_regions +from agent_code_guard.analysis.facts import AnalysisFacts, FileFacts from agent_code_guard.file_selection import resolve_invocation from agent_code_guard.guards import callable_size, loc from agent_code_guard.invocation import load_configuration @@ -69,6 +70,24 @@ def test_vue_regions_share_one_original_line_index_and_map_utf8_crlf(self): point = regions[0].original_point(1, len("const label = '".encode("utf-8")) + 2) self.assertEqual((point.line, point.byte_column), (2, 18)) + def test_reporting_path_lookups_do_not_rescan_analyzed_files(self): + class CountingFiles(tuple): + iterations = 0 + + def __iter__(self): + self.iterations += 1 + return super().__iter__() + + files = CountingFiles( + FileFacts(Path(f"file-{index}.py"), (), (), (), 1, f"src/file-{index}.py") + for index in range(100) + ) + facts = AnalysisFacts(files) + construction_iterations = files.iterations + for _ in range(100): + self.assertEqual(facts.reporting_path_for(Path("file-99.py")), "src/file-99.py") + self.assertEqual(files.iterations, construction_iterations) + if __name__ == "__main__": unittest.main()