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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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.
- Reuse one immutable invocation context for configuration and canonical selected-file identities during ordinary multi-guard analysis, 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
Expand Down
21 changes: 9 additions & 12 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,23 +325,20 @@ 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
`tools/benchmark_wayfarer.py`. 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"
```console
python tools/benchmark_wayfarer.py --wayfarer-path C:\bench\Wayfarer --config-path C:\bench\wayfarer-code-guard.config.json --output-directory C:\bench\results\after --installation-mode "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.
a normal-run cProfile file. It requires successful complete Git-status checks
before and after the run, and fails if verification fails or analysis creates
repository metadata. It rejects output paths equal to or beneath the target,
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.
18 changes: 16 additions & 2 deletions src/agent_code_guard/code_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,9 +339,23 @@ def run_analysis(
if baseline is not None:
loc_baseline.validate_paths(context.root, baseline)
loc_baseline.validate_overlap(baseline, loc_config)
error = "baseline analysis scope is outside analysis root"
try:
current_root = context.root.resolve(strict=True)
except OSError as exc:
raise ValueError(f"{error}: {context.root}") from exc
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}")
try:
current_path = selected.physical_path.resolve(strict=True)
valid = (
not selected.physical_path.is_symlink()
and current_path.is_file()
and current_path.is_relative_to(current_root)
)
except OSError:
valid = False
if not valid:
raise ValueError(f"{error}: {selected.physical_path}")
baseline = dict(baseline)
for target in linked_targets or set():
baseline.pop(target.relative_to(context.root).as_posix(), None)
Expand Down
76 changes: 76 additions & 0 deletions tests/test_benchmark_wayfarer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import subprocess
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from tools import benchmark_wayfarer


class BenchmarkWayfarerTests(unittest.TestCase):
def test_target_output_is_rejected_before_directory_creation(self) -> None:
with tempfile.TemporaryDirectory() as value:
target = Path(value)
config = target.parent / "benchmark-config.json"
config.write_text("{}", encoding="utf-8")

with patch.object(Path, "mkdir", side_effect=AssertionError("output directory created")):
with self.assertRaisesRegex(ValueError, "outside the disposable Wayfarer checkout"):
benchmark_wayfarer.main([
"--wayfarer-path", str(target),
"--config-path", str(config),
"--output-directory", str(target),
])

def test_external_and_similarly_prefixed_sibling_outputs_are_allowed(self) -> None:
with tempfile.TemporaryDirectory() as value:
parent = Path(value)
target = parent / "Wayfarer"
target.mkdir()

for output in (parent / "results", parent / "Wayfarer-other"):
self.assertEqual(
benchmark_wayfarer.validate_output_directory(target, output),
output.absolute(),
)

def test_descendant_output_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as value:
target = Path(value) / "Wayfarer"
target.mkdir()

with self.assertRaisesRegex(ValueError, "outside the disposable Wayfarer checkout"):
benchmark_wayfarer.validate_output_directory(target, target / "results")

def test_external_symlink_into_target_is_rejected(self) -> None:
with tempfile.TemporaryDirectory() as value:
parent = Path(value)
target = parent / "Wayfarer"
sink = target / "sink"
sink.mkdir(parents=True)
link = parent / "external-link"
try:
link.symlink_to(sink, target_is_directory=True)
except OSError as exc:
self.skipTest(f"directory symlink creation is unavailable: {exc}")

with self.assertRaisesRegex(ValueError, "outside the disposable Wayfarer checkout"):
benchmark_wayfarer.validate_output_directory(target, link / "results")

def test_failed_pre_run_git_status_is_rejected(self) -> None:
failed = subprocess.CompletedProcess([], 1, stdout="possibly clean", stderr="failure")
with patch.object(benchmark_wayfarer.subprocess, "run", return_value=failed):
with self.assertRaisesRegex(RuntimeError, "pre-run Git status verification failed"):
benchmark_wayfarer.git_status(Path("checkout"), "pre-run")

def test_failed_post_run_git_status_is_rejected(self) -> None:
failed = subprocess.CompletedProcess([], 1, stdout="", stderr="failure")
with patch.object(benchmark_wayfarer.subprocess, "run", return_value=failed):
with self.assertRaisesRegex(RuntimeError, "post-run Git status verification failed"):
benchmark_wayfarer.git_status(Path("checkout"), "post-run")


if __name__ == "__main__":
unittest.main()
37 changes: 37 additions & 0 deletions tests/test_shared_invocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from types import SimpleNamespace
from unittest.mock import patch

from agent_code_guard import code_guard
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
Expand Down Expand Up @@ -88,6 +89,42 @@ def __iter__(self):
self.assertEqual(facts.reporting_path_for(Path("file-99.py")), "src/file-99.py")
self.assertEqual(files.iterations, construction_iterations)

def test_loaded_baseline_rejects_selected_path_swapped_to_external_symlink(self):
with tempfile.TemporaryDirectory() as value, tempfile.TemporaryDirectory() as outside_value:
root = Path(value)
source = root / "sample.py"
outside = Path(outside_value) / "outside.py"
source.write_text("inside = 1\n", encoding="utf-8")
outside.write_text("outside = 1\n", encoding="utf-8")
args = code_guard.parser().parse_args(["."])
with patch("agent_code_guard.file_selection.find_repo_root", return_value=None):
context = resolve_invocation(args, root, {})
source.unlink()
try:
source.symlink_to(outside)
except OSError as exc:
self.skipTest(f"symlink creation is unavailable: {exc}")

with patch.object(code_guard.loc, "run", side_effect=AssertionError("outside file analyzed")):
with self.assertRaisesRegex(ValueError, "baseline analysis scope is outside analysis root"):
code_guard.run_analysis(
context, args, baseline_override={}, baseline_loaded=True,
)

def test_loaded_baseline_accepts_unchanged_selected_file(self):
with tempfile.TemporaryDirectory() as value:
root = Path(value)
(root / "sample.py").write_text("inside = 1\n", encoding="utf-8")
args = code_guard.parser().parse_args(["."])
with patch("agent_code_guard.file_selection.find_repo_root", return_value=None):
context = resolve_invocation(args, root, {})

completed = code_guard.run_analysis(
context, args, baseline_override={}, baseline_loaded=True,
)

self.assertEqual(completed.results[0].findings[0].path, "sample.py")


if __name__ == "__main__":
unittest.main()
107 changes: 0 additions & 107 deletions tools/benchmark-wayfarer.ps1

This file was deleted.

Loading
Loading