Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion research/complexity_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion research/default_threshold_sample.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}


Expand Down
8 changes: 4 additions & 4 deletions src/agent_code_guard/analysis/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)


Expand Down
3 changes: 1 addition & 2 deletions src/agent_code_guard/analysis/callable_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<callback@{point.line}:{point.byte_column}>"


Expand Down
22 changes: 21 additions & 1 deletion src/agent_code_guard/analysis/facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -82,11 +84,20 @@ class FileFacts:
controls: tuple[ControlFlowFact, ...]
decisions: tuple[DecisionFact, ...]
region_count: int
reporting_path: str | None = None


@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, ...]:
Expand All @@ -99,3 +110,12 @@ 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 = self._reporting_paths.get(path)
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()
24 changes: 15 additions & 9 deletions src/agent_code_guard/analysis/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,16 +15,20 @@
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))


@dataclass(frozen=True)
class UnavailableAnalysis:
path: Path
reporting_path: str
language: str
kind: str
message: str
Expand All @@ -35,27 +41,27 @@ class BatchAnalysis:


def analyze_files_for_runner(
files: 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:
path = Path(value)
for selected in files:
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, selected.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 = []
Expand Down Expand Up @@ -83,4 +89,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)
38 changes: 24 additions & 14 deletions src/agent_code_guard/analysis/regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,21 +30,31 @@ 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
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),
)


Expand All @@ -69,6 +80,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
Expand All @@ -83,15 +95,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]:
Expand Down
Loading
Loading