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.
- {agent_code_guard-0.3.0/src/agent_code_guard.egg-info → agent_code_guard-0.3.1}/PKG-INFO +1 -1
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/pyproject.toml +1 -1
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/adapters.py +4 -4
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/callable_identity.py +1 -2
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/facts.py +21 -1
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/pipeline.py +15 -9
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/regions.py +24 -14
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/code_guard.py +68 -35
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/config_validation.py +17 -19
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/file_selection.py +42 -17
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/callable_size.py +14 -20
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/complexity.py +14 -19
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/loc.py +27 -36
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/markdown_document_size.py +16 -18
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/markdown_section_size.py +16 -18
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/nesting.py +14 -19
- agent_code_guard-0.3.1/src/agent_code_guard/invocation.py +54 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/loc_baseline.py +10 -3
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/markdown/facts.py +1 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/markdown/scanner.py +15 -6
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1/src/agent_code_guard.egg-info}/PKG-INFO +1 -1
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/SOURCES.txt +1 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/LICENSE +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/MANIFEST.in +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/README.md +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/setup.cfg +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/LICENSE.txt +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/SKILL.md +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/agents/openai.yaml +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/callable-size-policy.md +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/complexity-policy.md +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/loc-policy.md +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/markdown-size-policy.md +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/skills/code-guard/references/nesting-policy.md +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/__init__.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/__init__.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/branch_normalization.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/csharp_compat.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/errors.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/language_specs.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/provider.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/syntax_nodes.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/doctor.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/guards/__init__.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/human_output.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/markdown/__init__.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/path_matching.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/reporting.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/result_model.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/skill_distribution.py +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/dependency_links.txt +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/entry_points.txt +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/requires.txt +0 -0
- {agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
120
|
-
|
|
119
|
+
start = callable_source_start(node, region.language)
|
|
120
|
+
end = _range_end_node(node, region.language)
|
|
121
121
|
return SourceRange(
|
|
122
|
-
region.
|
|
123
|
-
region.
|
|
122
|
+
region.original_point_at_byte(start.start_byte),
|
|
123
|
+
region.original_point_at_byte(end.end_byte),
|
|
124
124
|
)
|
|
125
125
|
|
|
126
126
|
|
{agent_code_guard-0.3.0 → agent_code_guard-0.3.1}/src/agent_code_guard/analysis/callable_identity.py
RENAMED
|
@@ -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
|
-
|
|
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[
|
|
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 = [
|
|
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[
|
|
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
|
|
45
|
-
path =
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
46
|
-
self.
|
|
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
|
|
91
|
-
|
|
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 .
|
|
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
|
-
|
|
337
|
-
|
|
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(
|
|
340
|
+
loc_baseline.validate_paths(context.root, baseline)
|
|
340
341
|
loc_baseline.validate_overlap(baseline, loc_config)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
355
|
-
if loc_config.enabled and loc.should_include(
|
|
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(
|
|
361
|
-
batch = analysis.analyze_files_for_runner(
|
|
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(
|
|
379
|
+
results.append(callable_size.run(context.root, callable_size_config, facts))
|
|
365
380
|
if nesting_config.enabled:
|
|
366
|
-
results.append(nesting.run(
|
|
381
|
+
results.append(nesting.run(context.root, nesting_config, facts))
|
|
367
382
|
if complexity_config.enabled:
|
|
368
|
-
results.append(complexity.run(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
384
|
-
selected = len(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
459
|
-
|
|
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,
|
|
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(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
81
|
+
if isinstance(item, Mapping):
|
|
84
82
|
_reject_unknown(item, allowed, f"{path}[{index}]")
|
|
85
83
|
|
|
86
84
|
|
|
87
|
-
def _reject_unknown(value:
|
|
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())
|
|
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
|
-
|
|
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
|
|
63
|
-
exclusions = load_scope_exclusions(args,
|
|
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
|
|
66
|
-
if any(matches_path_glob(
|
|
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,
|
|
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,
|
|
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,
|
|
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):
|