agent-code-guard 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {agent_code_guard-0.3.0/src/agent_code_guard.egg-info → agent_code_guard-0.3.1}/PKG-INFO +1 -1
  2. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/pyproject.toml +1 -1
  3. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/adapters.py +4 -4
  4. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/callable_identity.py +1 -2
  5. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/facts.py +21 -1
  6. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/pipeline.py +15 -9
  7. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/regions.py +24 -14
  8. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/code_guard.py +68 -35
  9. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/config_validation.py +17 -19
  10. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/file_selection.py +42 -17
  11. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/callable_size.py +14 -20
  12. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/complexity.py +14 -19
  13. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/loc.py +27 -36
  14. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/markdown_document_size.py +16 -18
  15. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/markdown_section_size.py +16 -18
  16. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/nesting.py +14 -19
  17. agent_code_guard-0.3.1/src/agent_code_guard/invocation.py +54 -0
  18. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/loc_baseline.py +10 -3
  19. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/markdown/facts.py +1 -0
  20. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/markdown/scanner.py +15 -6
  21. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1/src/agent_code_guard.egg-info}/PKG-INFO +1 -1
  22. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/SOURCES.txt +1 -0
  23. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/LICENSE +0 -0
  24. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/MANIFEST.in +0 -0
  25. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/README.md +0 -0
  26. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/setup.cfg +0 -0
  27. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/LICENSE.txt +0 -0
  28. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/SKILL.md +0 -0
  29. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/agents/openai.yaml +0 -0
  30. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/callable-size-policy.md +0 -0
  31. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/complexity-policy.md +0 -0
  32. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/loc-policy.md +0 -0
  33. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/markdown-size-policy.md +0 -0
  34. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/nesting-policy.md +0 -0
  35. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/__init__.py +0 -0
  36. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/__init__.py +0 -0
  37. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/branch_normalization.py +0 -0
  38. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/csharp_compat.py +0 -0
  39. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/errors.py +0 -0
  40. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/language_specs.py +0 -0
  41. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/provider.py +0 -0
  42. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/syntax_nodes.py +0 -0
  43. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/doctor.py +0 -0
  44. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/__init__.py +0 -0
  45. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/human_output.py +0 -0
  46. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/markdown/__init__.py +0 -0
  47. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/path_matching.py +0 -0
  48. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/reporting.py +0 -0
  49. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/result_model.py +0 -0
  50. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/skill_distribution.py +0 -0
  51. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/dependency_links.txt +0 -0
  52. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/entry_points.txt +0 -0
  53. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/requires.txt +0 -0
  54. {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-code-guard
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Deterministic cross-language guardrails for agent-assisted development
5
5
  License-Expression: MIT
6
6
  Project-URL: Source, https://github.com/stef-k/agent-code-guard
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agent-code-guard"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Deterministic cross-language guardrails for agent-assisted development"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -116,11 +116,11 @@ def _range_end_node(node, language: str):
116
116
 
117
117
  def _callable_range(node, region: ExecutableRegion) -> SourceRange:
118
118
  """Snapshot provider points once before mapping them to original source."""
119
- start_row, start_column = callable_source_start(node, region.language).start_point
120
- end_row, end_column = _range_end_node(node, region.language).end_point
119
+ start = callable_source_start(node, region.language)
120
+ end = _range_end_node(node, region.language)
121
121
  return SourceRange(
122
- region.original_point(start_row, start_column),
123
- region.original_point(end_row, end_column),
122
+ region.original_point_at_byte(start.start_byte),
123
+ region.original_point_at_byte(end.end_byte),
124
124
  )
125
125
 
126
126
 
@@ -272,8 +272,7 @@ def _javascript_lexical_name(node, source: bytes) -> str | None:
272
272
 
273
273
 
274
274
  def _callback_name(node, region: ExecutableRegion) -> str:
275
- row, column = node.start_point
276
- point = region.original_point(row, column)
275
+ point = region.original_point_at_byte(node.start_byte)
277
276
  return f"<callback@{point.line}:{point.byte_column}>"
278
277
 
279
278
 
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
6
  from pathlib import Path
7
+ from types import MappingProxyType
8
+ from typing import Mapping
7
9
 
8
10
 
9
11
  @dataclass(frozen=True, order=True)
@@ -82,11 +84,20 @@ class FileFacts:
82
84
  controls: tuple[ControlFlowFact, ...]
83
85
  decisions: tuple[DecisionFact, ...]
84
86
  region_count: int
87
+ reporting_path: str | None = None
85
88
 
86
89
 
87
90
  @dataclass(frozen=True)
88
91
  class AnalysisFacts:
89
92
  files: tuple[FileFacts, ...]
93
+ _reporting_paths: Mapping[Path, str] = field(init=False, repr=False, compare=False)
94
+
95
+ def __post_init__(self) -> None:
96
+ object.__setattr__(self, "_reporting_paths", MappingProxyType({
97
+ file.path: file.reporting_path
98
+ for file in self.files
99
+ if file.reporting_path is not None
100
+ }))
90
101
 
91
102
  @property
92
103
  def callables(self) -> tuple[CallableFact, ...]:
@@ -99,3 +110,12 @@ class AnalysisFacts:
99
110
  @property
100
111
  def decisions(self) -> tuple[DecisionFact, ...]:
101
112
  return tuple(fact for file in self.files for fact in file.decisions)
113
+
114
+ def reporting_path_for(self, path: Path, root: Path | None = None) -> str:
115
+ stored = self._reporting_paths.get(path)
116
+ if stored is not None:
117
+ return stored
118
+ try:
119
+ return path.relative_to(root).as_posix() if root is not None else path.as_posix()
120
+ except ValueError:
121
+ return path.as_posix()
@@ -5,6 +5,8 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
 
8
+ from ..invocation import SelectedFile
9
+
8
10
  from .adapters import extract_facts
9
11
  from .csharp_compat import corrected_csharp_root
10
12
  from .errors import ProviderUnavailableError, SyntaxAnalysisError
@@ -13,16 +15,20 @@ from .provider import ParserProvider, TreeSitterProvider
13
15
  from .regions import executable_regions, is_applicable
14
16
 
15
17
 
16
- def analyze_files(files: tuple[Path, ...] | list[Path], provider: ParserProvider | None = None) -> AnalysisFacts:
18
+ def analyze_files(files: tuple[SelectedFile, ...] | list[SelectedFile], provider: ParserProvider | None = None) -> AnalysisFacts:
17
19
  """Analyze only applicable entries from the already-resolved caller scope."""
18
20
  active_provider = provider or TreeSitterProvider()
19
- results = [_analyze_file(Path(path), active_provider) for path in files if is_applicable(Path(path))]
21
+ results = [
22
+ _analyze_file(selected.physical_path, active_provider, selected.reporting_path)
23
+ for selected in files if is_applicable(selected.physical_path)
24
+ ]
20
25
  return AnalysisFacts(tuple(results))
21
26
 
22
27
 
23
28
  @dataclass(frozen=True)
24
29
  class UnavailableAnalysis:
25
30
  path: Path
31
+ reporting_path: str
26
32
  language: str
27
33
  kind: str
28
34
  message: str
@@ -35,27 +41,27 @@ class BatchAnalysis:
35
41
 
36
42
 
37
43
  def analyze_files_for_runner(
38
- files: tuple[Path, ...] | list[Path], provider: ParserProvider | None = None,
44
+ files: tuple[SelectedFile, ...], provider: ParserProvider | None = None,
39
45
  ) -> BatchAnalysis:
40
46
  """Analyze selected files independently while retaining only known unavailable evidence."""
41
47
  active_provider = provider or TreeSitterProvider()
42
48
  results: list[FileFacts] = []
43
49
  unavailable: list[UnavailableAnalysis] = []
44
- for value in files:
45
- path = Path(value)
50
+ for selected in files:
51
+ path = selected.physical_path
46
52
  if not is_applicable(path):
47
53
  continue
48
54
  try:
49
- results.append(_analyze_file(path, active_provider))
55
+ results.append(_analyze_file(path, active_provider, selected.reporting_path))
50
56
  except (SyntaxAnalysisError, ProviderUnavailableError) as exc:
51
57
  if exc.language is None:
52
58
  raise
53
59
  kind = "syntax" if isinstance(exc, SyntaxAnalysisError) else "provider"
54
- unavailable.append(UnavailableAnalysis(path, exc.language, kind, str(exc)))
60
+ unavailable.append(UnavailableAnalysis(path, selected.reporting_path, exc.language, kind, str(exc)))
55
61
  return BatchAnalysis(AnalysisFacts(tuple(results)), tuple(unavailable))
56
62
 
57
63
 
58
- def _analyze_file(path: Path, provider: ParserProvider) -> FileFacts:
64
+ def _analyze_file(path: Path, provider: ParserProvider, reporting_path: str | None = None) -> FileFacts:
59
65
  callables = []
60
66
  controls = []
61
67
  decisions = []
@@ -83,4 +89,4 @@ def _analyze_file(path: Path, provider: ParserProvider) -> FileFacts:
83
89
  callables.extend(region_callables)
84
90
  controls.extend(region_controls)
85
91
  decisions.extend(region_decisions)
86
- return FileFacts(path, tuple(callables), tuple(controls), tuple(decisions), len(regions))
92
+ return FileFacts(path, tuple(callables), tuple(controls), tuple(decisions), len(regions), reporting_path)
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from dataclasses import dataclass
6
+ from bisect import bisect_right
6
7
  from pathlib import Path
7
8
 
8
9
  from .errors import SyntaxAnalysisError
@@ -29,21 +30,31 @@ class ExecutableRegion:
29
30
  source: bytes
30
31
  original_source: bytes
31
32
  original_byte_offset: int = 0
33
+ original_line_starts: tuple[int, ...] | None = None
34
+ local_line_starts: tuple[int, ...] | None = None
35
+
36
+ def __post_init__(self) -> None:
37
+ if self.original_line_starts is None:
38
+ object.__setattr__(self, "original_line_starts", _line_starts(self.original_source))
39
+ if self.local_line_starts is None:
40
+ object.__setattr__(self, "local_line_starts", _line_starts(self.source))
32
41
 
33
42
  def original_point(self, local_row: int, local_byte_column: int) -> SourcePoint:
34
- absolute = self.original_byte_offset + _byte_at_point(self.source, local_row, local_byte_column)
35
- prefix = self.original_source[:absolute]
36
- line = prefix.count(b"\n") + 1
37
- newline = prefix.rfind(b"\n")
38
- byte_column = absolute + 1 if newline < 0 else absolute - newline
43
+ local_offset = self.local_line_starts[local_row] + local_byte_column
44
+ return self.original_point_at_byte(local_offset)
45
+
46
+ def original_point_at_byte(self, local_byte_offset: int) -> SourcePoint:
47
+ """Map a parser byte offset without reconstructing its local row prefix."""
48
+ absolute = self.original_byte_offset + local_byte_offset
49
+ row = bisect_right(self.original_line_starts, absolute) - 1
50
+ line = row + 1
51
+ byte_column = absolute - self.original_line_starts[row] + 1
39
52
  return SourcePoint(line, byte_column, absolute)
40
53
 
41
54
  def original_range(self, node) -> SourceRange:
42
- start_row, start_column = node.start_point
43
- end_row, end_column = node.end_point
44
55
  return SourceRange(
45
- self.original_point(start_row, start_column),
46
- self.original_point(end_row, end_column),
56
+ self.original_point_at_byte(node.start_byte),
57
+ self.original_point_at_byte(node.end_byte),
47
58
  )
48
59
 
49
60
 
@@ -69,6 +80,7 @@ def _vue_regions(path: Path, source: bytes, provider: ParserProvider) -> tuple[E
69
80
  f"unable to parse {path}: Vue container syntax tree contains errors", language="vue",
70
81
  )
71
82
  regions: list[ExecutableRegion] = []
83
+ original_line_starts = _line_starts(source)
72
84
  for element in root.named_children:
73
85
  if element.type != "script_element":
74
86
  continue
@@ -83,15 +95,13 @@ def _vue_regions(path: Path, source: bytes, provider: ParserProvider) -> tuple[E
83
95
  if raw_text is not None:
84
96
  regions.append(ExecutableRegion(
85
97
  path, language, source[raw_text.start_byte:raw_text.end_byte], source, raw_text.start_byte,
98
+ original_line_starts,
86
99
  ))
87
100
  return tuple(regions)
88
101
 
89
102
 
90
- def _byte_at_point(source: bytes, row: int, column: int) -> int:
91
- position = 0
92
- for _ in range(row):
93
- position = source.index(b"\n", position) + 1
94
- return position + column
103
+ def _line_starts(source: bytes) -> tuple[int, ...]:
104
+ return (0, *(index + 1 for index, value in enumerate(source) if value == 10))
95
105
 
96
106
 
97
107
  def _attributes(start_tag, source: bytes) -> dict[str, str | None]:
@@ -12,12 +12,12 @@ import sys
12
12
  from pathlib import Path
13
13
 
14
14
  from .config_validation import validate_configuration
15
- from .file_selection import resolve_scope
15
+ from .file_selection import ResolvedScope, resolve_invocation, resolve_scope
16
16
  from .guards import callable_size, complexity, loc, markdown_document_size, markdown_section_size, nesting
17
17
  from .human_output import format_completed_analysis
18
18
  from . import loc_baseline
19
19
  from .result_model import GuardResult, aggregate_state, required_policies
20
- from .reporting import reporting_path
20
+ from .invocation import AnalysisContext, SelectedFile, load_configuration
21
21
  from .skill_distribution import export_skill, skill_path as installed_skill_path
22
22
 
23
23
  DISTRIBUTION_NAME = "agent-code-guard"
@@ -329,63 +329,78 @@ def run_guards(scope, args: argparse.Namespace) -> list[GuardResult]:
329
329
 
330
330
 
331
331
  def run_analysis(
332
- scope, args: argparse.Namespace, baseline_override: dict[str, int] | None = None,
332
+ scope: AnalysisContext | ResolvedScope, args: argparse.Namespace, baseline_override: dict[str, int] | None = None,
333
333
  baseline_loaded: bool = False, linked_targets: set[Path] | None = None,
334
334
  ) -> CompletedAnalysis:
335
335
  """Load guard configuration, then construct shared syntax facts at most once."""
336
- loc_config = loc.load_config(args)
337
- baseline = baseline_override if baseline_loaded else loc_baseline.load_if_present(scope.root)
336
+ context = scope if isinstance(scope, AnalysisContext) else _legacy_context(scope, args)
337
+ loc_config = loc.load_config(args, context.configuration)
338
+ baseline = baseline_override if baseline_loaded else loc_baseline.load_if_present(context.root)
338
339
  if baseline is not None:
339
- loc_baseline.validate_paths(scope.root, baseline)
340
+ loc_baseline.validate_paths(context.root, baseline)
340
341
  loc_baseline.validate_overlap(baseline, loc_config)
341
- for path in scope.files:
342
- if not path.is_file() or not path.resolve().is_relative_to(scope.root.resolve()):
343
- raise ValueError(f"baseline analysis scope is outside analysis root: {path}")
342
+ error = "baseline analysis scope is outside analysis root"
343
+ try:
344
+ current_root = context.root.resolve(strict=True)
345
+ except OSError as exc:
346
+ raise ValueError(f"{error}: {context.root}") from exc
347
+ for selected in context.selected_files:
348
+ try:
349
+ current_path = selected.physical_path.resolve(strict=True)
350
+ valid = (
351
+ not selected.physical_path.is_symlink()
352
+ and current_path.is_file()
353
+ and current_path.is_relative_to(current_root)
354
+ )
355
+ except OSError:
356
+ valid = False
357
+ if not valid:
358
+ raise ValueError(f"{error}: {selected.physical_path}")
344
359
  baseline = dict(baseline)
345
360
  for target in linked_targets or set():
346
- baseline.pop(target.relative_to(scope.root).as_posix(), None)
347
- callable_size_config = callable_size.load_config(args)
348
- nesting_config = nesting.load_config(args)
349
- complexity_config = complexity.load_config(args)
350
- markdown_document_config = markdown_document_size.load_config(args)
351
- markdown_section_config = markdown_section_size.load_config(args)
352
- results = [loc.run(scope.root, loc_config, scope.files, baseline)]
361
+ baseline.pop(target.relative_to(context.root).as_posix(), None)
362
+ callable_size_config = callable_size.load_config(args, context.configuration)
363
+ nesting_config = nesting.load_config(args, context.configuration)
364
+ complexity_config = complexity.load_config(args, context.configuration)
365
+ markdown_document_config = markdown_document_size.load_config(args, context.configuration)
366
+ markdown_section_config = markdown_section_size.load_config(args, context.configuration)
367
+ results = [loc.run(context.root, loc_config, context.selected_files, baseline)]
353
368
  analyzed_files = {
354
- path for path in scope.files
355
- if loc_config.enabled and loc.should_include(path, loc_config, scope.root)
369
+ selected.reporting_path for selected in context.selected_files
370
+ if loc_config.enabled and loc.should_include(selected, loc_config)
356
371
  }
357
372
  needs_analysis = callable_size_config.enabled or nesting_config.enabled or complexity_config.enabled
358
373
  if needs_analysis:
359
374
  analysis = import_module("agent_code_guard.analysis.pipeline")
360
- analyzed_files.update(path for path in scope.files if analysis.is_applicable(path))
361
- batch = analysis.analyze_files_for_runner(scope.files)
375
+ analyzed_files.update(selected.reporting_path for selected in context.selected_files if analysis.is_applicable(selected.physical_path))
376
+ batch = analysis.analyze_files_for_runner(context.selected_files)
362
377
  facts = batch.facts
363
378
  if callable_size_config.enabled:
364
- results.append(callable_size.run(scope.root, callable_size_config, facts))
379
+ results.append(callable_size.run(context.root, callable_size_config, facts))
365
380
  if nesting_config.enabled:
366
- results.append(nesting.run(scope.root, nesting_config, facts))
381
+ results.append(nesting.run(context.root, nesting_config, facts))
367
382
  if complexity_config.enabled:
368
- results.append(complexity.run(scope.root, complexity_config, facts))
383
+ results.append(complexity.run(context.root, complexity_config, facts))
369
384
  needs_markdown = markdown_document_config.enabled or markdown_section_config.enabled
370
- markdown_files = tuple(path for path in scope.files if path.suffix.lower() == ".md") if needs_markdown else ()
371
- analyzed_files.update(markdown_files)
385
+ markdown_files = tuple(selected for selected in context.selected_files if selected.physical_path.suffix.lower() == ".md") if needs_markdown else ()
386
+ analyzed_files.update(selected.reporting_path for selected in markdown_files)
372
387
  if markdown_files:
373
388
  markdown = import_module("agent_code_guard.markdown")
374
389
  markdown_facts = markdown.analyze_files(markdown_files)
375
390
  if markdown_document_config.enabled:
376
- results.append(markdown_document_size.run(scope.root, markdown_document_config, markdown_facts))
391
+ results.append(markdown_document_size.run(context.root, markdown_document_config, markdown_facts))
377
392
  if markdown_section_config.enabled:
378
- results.append(markdown_section_size.run(scope.root, markdown_section_config, markdown_facts))
393
+ results.append(markdown_section_size.run(context.root, markdown_section_config, markdown_facts))
379
394
  else:
380
395
  if markdown_document_config.enabled:
381
- results.append(markdown_document_size.run(scope.root, markdown_document_config, _empty_markdown_facts()))
396
+ results.append(markdown_document_size.run(context.root, markdown_document_config, _empty_markdown_facts()))
382
397
  if markdown_section_config.enabled:
383
- results.append(markdown_section_size.run(scope.root, markdown_section_config, _empty_markdown_facts()))
384
- selected = len(scope.files)
398
+ results.append(markdown_section_size.run(context.root, markdown_section_config, _empty_markdown_facts()))
399
+ selected = len(context.selected_files)
385
400
  analyzed = len(analyzed_files)
386
401
  unavailable = tuple(
387
402
  UnavailableEntry(
388
- reporting_path(item.path, scope.root), item.language, item.kind, item.message,
403
+ item.reporting_path, item.language, item.kind, item.message,
389
404
  )
390
405
  for item in (batch.unavailable if needs_analysis else ())
391
406
  )
@@ -399,7 +414,7 @@ def run_analysis(
399
414
  return CompletedAnalysis(
400
415
  results,
401
416
  ScopeSummary(
402
- selected, analyzed, selected - analyzed, len(scope.excluded_files),
417
+ selected, analyzed, selected - analyzed, len(context.excluded_files),
403
418
  len({entry.path for entry in unavailable}) if unavailable else None,
404
419
  ),
405
420
  unavailable,
@@ -407,6 +422,21 @@ def run_analysis(
407
422
  )
408
423
 
409
424
 
425
+ def _legacy_context(scope: ResolvedScope, args: argparse.Namespace) -> AnalysisContext:
426
+ """Focused-test adapter; the production runner constructs identities during selection."""
427
+ document = validate_configuration(args.config, Path.cwd())
428
+ def selected(path: Path) -> SelectedFile:
429
+ try:
430
+ report = path.relative_to(scope.root).as_posix()
431
+ except ValueError:
432
+ report = path.as_posix()
433
+ return SelectedFile(report, path)
434
+ return AnalysisContext(
435
+ scope.root, document, tuple(selected(path) for path in scope.files),
436
+ tuple(selected(path) for path in scope.excluded_files),
437
+ )
438
+
439
+
410
440
  def _empty_markdown_facts():
411
441
  """Avoid importing the scanner family for scopes with no applicable files."""
412
442
  from types import SimpleNamespace
@@ -455,13 +485,16 @@ def main() -> int:
455
485
  management_result = _management_mode(args)
456
486
  if management_result is not None:
457
487
  return management_result
458
- validate_configuration(args.config, Path.cwd())
459
- scope = resolve_scope(args, Path.cwd())
488
+ invocation = Path.cwd()
489
+ configuration = load_configuration(args.config, invocation)
490
+ validate_configuration(args.config, invocation, configuration)
491
+ scope = resolve_invocation(args, invocation, configuration)
460
492
  linked_targets: set[Path] = set()
461
493
  baseline_loaded = hasattr(scope, "root")
462
494
  if baseline_loaded and loc_baseline.baseline_path(scope.root).exists():
463
495
  linked_targets = loc_baseline.validate_explicit_scope(
464
- args.paths, Path.cwd(), scope.root, scope.files,
496
+ args.paths, invocation, scope.root,
497
+ tuple(selected.physical_path for selected in scope.selected_files),
465
498
  )
466
499
  baseline = loc_baseline.load_if_present(scope.root) if baseline_loaded else None
467
500
  data = payload(
@@ -2,10 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  from pathlib import Path
6
+ from collections.abc import Mapping, Sequence
7
7
  from typing import Any
8
8
 
9
+ from .invocation import load_configuration
10
+
9
11
  ROOT_KEYS = {"version", "scope", "guards"}
10
12
  SCOPE_KEYS = {"exclude"}
11
13
  GUARD_KEYS = {
@@ -40,28 +42,23 @@ LOC_ALLOWED_LARGE_FILE_KEYS = {"path", "reason"}
40
42
  LOC_OVERRIDE_KEYS = {"match", "warnAt", "failAt"}
41
43
 
42
44
 
43
- def validate_configuration(config: str | None, start: Path) -> None:
44
- """Load the configured document once and validate its known property names."""
45
- path = Path(config) if config else start / ".agent-tools" / "code-guard.config.json"
46
- if config and not path.exists():
47
- raise FileNotFoundError(f"config file not found: {config}")
48
- if not path.exists():
49
- return
50
- document = json.loads(path.read_text(encoding="utf-8"))
51
- if not isinstance(document, dict):
52
- raise ValueError("configuration must be an object")
45
+ def validate_configuration(
46
+ config: str | None, start: Path, document: Mapping[str, Any] | None = None,
47
+ ) -> Mapping[str, Any]:
48
+ """Validate known property names in an already-loaded document."""
49
+ document = load_configuration(config, start) if document is None else document
53
50
  _reject_unknown(document, ROOT_KEYS, "")
54
51
  _validate_object_keys(document.get("scope"), SCOPE_KEYS, "scope")
55
52
 
56
53
  guards = document.get("guards")
57
- if not isinstance(guards, dict):
58
- return
54
+ if not isinstance(guards, Mapping):
55
+ return document
59
56
  _reject_unknown(guards, GUARD_KEYS, "guards")
60
57
  for guard_name in REVIEW_GUARD_NAMES:
61
58
  _validate_object_keys(guards.get(guard_name), REVIEW_GUARD_KEYS, f"guards.{guard_name}")
62
59
  loc = guards.get("loc")
63
- if not isinstance(loc, dict):
64
- return
60
+ if not isinstance(loc, Mapping):
61
+ return document
65
62
  _reject_unknown(loc, LOC_KEYS, "guards.loc")
66
63
  _validate_items(
67
64
  loc.get("allowedLargeFiles"),
@@ -69,22 +66,23 @@ def validate_configuration(config: str | None, start: Path) -> None:
69
66
  "guards.loc.allowedLargeFiles",
70
67
  )
71
68
  _validate_items(loc.get("overrides"), LOC_OVERRIDE_KEYS, "guards.loc.overrides")
69
+ return document
72
70
 
73
71
 
74
72
  def _validate_object_keys(value: Any, allowed: set[str], path: str) -> None:
75
- if isinstance(value, dict):
73
+ if isinstance(value, Mapping):
76
74
  _reject_unknown(value, allowed, path)
77
75
 
78
76
 
79
77
  def _validate_items(value: Any, allowed: set[str], path: str) -> None:
80
- if not isinstance(value, list):
78
+ if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
81
79
  return
82
80
  for index, item in enumerate(value):
83
- if isinstance(item, dict):
81
+ if isinstance(item, Mapping):
84
82
  _reject_unknown(item, allowed, f"{path}[{index}]")
85
83
 
86
84
 
87
- def _reject_unknown(value: dict[str, Any], allowed: set[str], path: str) -> None:
85
+ def _reject_unknown(value: Mapping[str, Any], allowed: set[str], path: str) -> None:
88
86
  unknown = sorted(key for key in value if key not in allowed)
89
87
  if unknown:
90
88
  property_path = f"{path}.{unknown[0]}" if path else unknown[0]
@@ -2,13 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  import os
7
6
  import subprocess
7
+ from collections.abc import Mapping, Sequence
8
8
  from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import Protocol
11
11
 
12
+ from .invocation import AnalysisContext, JsonObject, SelectedFile
12
13
  from .path_matching import matches_path_glob, relative_or_absolute_path
13
14
 
14
15
  BUILTIN_PRUNED_DIRECTORIES = {".git", "node_modules", "bin", "obj"}
@@ -30,6 +31,19 @@ class ResolvedScope:
30
31
  excluded_files: tuple[Path, ...] = ()
31
32
 
32
33
 
34
+ def resolve_invocation(
35
+ args: SelectionArgs, start: Path, configuration: JsonObject,
36
+ ) -> AnalysisContext:
37
+ """Resolve every physical/reporting identity once for one invocation."""
38
+ scope = _resolve_scope(args, start, configuration)
39
+ return AnalysisContext(
40
+ scope.root,
41
+ configuration,
42
+ tuple(SelectedFile(_reporting_path(path, scope.root), path) for path in scope.files),
43
+ tuple(SelectedFile(_reporting_path(path, scope.root), path) for path in scope.excluded_files),
44
+ )
45
+
46
+
33
47
  def find_repo_root(start: Path) -> Path | None:
34
48
  """Return the enclosing Git root, without inventing one when Git is absent."""
35
49
  try:
@@ -37,14 +51,19 @@ def find_repo_root(start: Path) -> Path | None:
37
51
  ["git", "rev-parse", "--show-toplevel"], cwd=start, check=True,
38
52
  text=True, capture_output=True,
39
53
  )
40
- return Path(result.stdout.strip()).resolve()
54
+ return _canonicalize(Path(result.stdout.strip()))
41
55
  except Exception:
42
56
  return None
43
57
 
44
58
 
45
59
  def resolve_scope(args: SelectionArgs, start: Path) -> ResolvedScope:
46
60
  """Resolve and normalize the complete file scope shared by all guards."""
47
- working_root = start.resolve()
61
+ from .invocation import load_configuration
62
+ return _resolve_scope(args, start, load_configuration(getattr(args, "config", None), start))
63
+
64
+
65
+ def _resolve_scope(args: SelectionArgs, start: Path, configuration: JsonObject) -> ResolvedScope:
66
+ working_root = _canonicalize(start)
48
67
  git_root = find_repo_root(working_root)
49
68
  root = git_root or working_root
50
69
  validate_selection_args(args, git_root)
@@ -59,16 +78,24 @@ def resolve_scope(args: SelectionArgs, start: Path) -> ResolvedScope:
59
78
  else:
60
79
  files = existing_files(expand_paths(paths, git_root))
61
80
 
62
- normalized = tuple(dict.fromkeys(path.resolve() for path in files))
63
- exclusions = load_scope_exclusions(args, working_root)
81
+ normalized = tuple(dict.fromkeys(_canonicalize(path) for path in files))
82
+ exclusions = load_scope_exclusions(args, configuration)
83
+ identities = tuple((path, _reporting_path(path, root)) for path in normalized)
64
84
  excluded = tuple(
65
- path for path in normalized
66
- if any(matches_path_glob(relative_or_absolute_path(path, root), pattern) for pattern in exclusions)
85
+ path for path, reporting_path in identities
86
+ if any(matches_path_glob(reporting_path, pattern) for pattern in exclusions)
67
87
  )
68
88
  excluded_set = set(excluded)
69
89
  return ResolvedScope(root, tuple(path for path in normalized if path not in excluded_set), excluded)
70
90
 
71
91
 
92
+ def _reporting_path(canonical_path: Path, canonical_root: Path) -> str:
93
+ try:
94
+ return canonical_path.relative_to(canonical_root).as_posix()
95
+ except ValueError:
96
+ return canonical_path.as_posix()
97
+
98
+
72
99
  def resolve_explicit_paths(values: list[str], working_root: Path) -> list[Path]:
73
100
  """Resolve and validate positional paths against the caller's working directory."""
74
101
  paths = [Path(value) if Path(value).is_absolute() else working_root / value for value in values]
@@ -83,6 +110,11 @@ def resolve_explicit_paths(values: list[str], working_root: Path) -> list[Path]:
83
110
  return paths
84
111
 
85
112
 
113
+ def _canonicalize(path: Path) -> Path:
114
+ """Owned filesystem identity seam; never call it from guard loops."""
115
+ return path.resolve()
116
+
117
+
86
118
  def bound_git_candidates(candidates: list[Path], bounds: list[Path]) -> list[Path]:
87
119
  """Intersect Git-selected files with the union of positional file/directory bounds."""
88
120
  normalized_bounds = [(path.resolve(), path.is_dir()) for path in bounds]
@@ -95,19 +127,12 @@ def bound_git_candidates(candidates: list[Path], bounds: list[Path]) -> list[Pat
95
127
  ]
96
128
 
97
129
 
98
- def load_scope_exclusions(args: SelectionArgs, start: Path) -> list[str]:
99
- explicit_config = getattr(args, "config", None)
100
- config_path = Path(explicit_config) if explicit_config else start / ".agent-tools" / "code-guard.config.json"
101
- if explicit_config and not config_path.exists():
102
- raise FileNotFoundError(f"config file not found: {explicit_config}")
103
- document = json.loads(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
104
- if not isinstance(document, dict):
105
- raise ValueError("configuration must be an object")
130
+ def load_scope_exclusions(args: SelectionArgs, document: JsonObject) -> list[str]:
106
131
  scope = document.get("scope", {})
107
- if not isinstance(scope, dict):
132
+ if not isinstance(scope, Mapping):
108
133
  raise ValueError("scope must be an object")
109
134
  exclude = scope.get("exclude", [])
110
- if not isinstance(exclude, list) or any(not isinstance(pattern, str) for pattern in exclude):
135
+ if not isinstance(exclude, Sequence) or isinstance(exclude, (str, bytes)) or any(not isinstance(pattern, str) for pattern in exclude):
111
136
  raise ValueError("scope.exclude must be an array of strings")
112
137
  combined = [*exclude, *getattr(args, "scope_exclude", [])]
113
138
  if any(not isinstance(pattern, str) or not pattern.strip() for pattern in combined):