agent-code-guard 0.1.0__py3-none-any.whl
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/__init__.py +1 -0
- agent_code_guard/analysis/__init__.py +13 -0
- agent_code_guard/analysis/adapters.py +608 -0
- agent_code_guard/analysis/errors.py +13 -0
- agent_code_guard/analysis/facts.py +101 -0
- agent_code_guard/analysis/language_specs.py +82 -0
- agent_code_guard/analysis/pipeline.py +37 -0
- agent_code_guard/analysis/provider.py +45 -0
- agent_code_guard/analysis/regions.py +108 -0
- agent_code_guard/code_guard.py +236 -0
- agent_code_guard/config_validation.py +90 -0
- agent_code_guard/file_selection.py +228 -0
- agent_code_guard/guards/__init__.py +1 -0
- agent_code_guard/guards/callable_size.py +79 -0
- agent_code_guard/guards/complexity.py +94 -0
- agent_code_guard/guards/loc.py +235 -0
- agent_code_guard/guards/markdown_document_size.py +66 -0
- agent_code_guard/guards/markdown_section_size.py +66 -0
- agent_code_guard/guards/nesting.py +109 -0
- agent_code_guard/markdown/__init__.py +6 -0
- agent_code_guard/markdown/facts.py +27 -0
- agent_code_guard/markdown/scanner.py +109 -0
- agent_code_guard/path_matching.py +25 -0
- agent_code_guard/reporting.py +11 -0
- agent_code_guard/result_model.py +128 -0
- agent_code_guard/skill_distribution.py +96 -0
- agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/LICENSE.txt +21 -0
- agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/SKILL.md +138 -0
- agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/agents/openai.yaml +8 -0
- agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/callable-size-policy.md +39 -0
- agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/complexity-policy.md +39 -0
- agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/loc-policy.md +40 -0
- agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/markdown-size-policy.md +16 -0
- agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/nesting-policy.md +37 -0
- agent_code_guard-0.1.0.dist-info/METADATA +206 -0
- agent_code_guard-0.1.0.dist-info/RECORD +40 -0
- agent_code_guard-0.1.0.dist-info/WHEEL +5 -0
- agent_code_guard-0.1.0.dist-info/entry_points.txt +2 -0
- agent_code_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
- agent_code_guard-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Canonical LOC guard, migrated from Agent LOC Guard commit 75ab39d."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ..result_model import Finding, GuardResult
|
|
12
|
+
from ..path_matching import matches_path_glob, relative_or_absolute_path
|
|
13
|
+
|
|
14
|
+
DEFAULT_WARN_AT = 400
|
|
15
|
+
DEFAULT_FAIL_AT = 600
|
|
16
|
+
DEFAULT_INCLUDE_EXTENSIONS = {
|
|
17
|
+
".cs", ".cshtml", ".razor", ".js", ".jsx", ".ts", ".tsx", ".py", ".java",
|
|
18
|
+
".kt", ".kts", ".scala", ".go", ".rs", ".swift", ".dart", ".zig", ".cpp",
|
|
19
|
+
".c", ".h", ".hpp", ".m", ".mm", ".fs", ".fsx", ".vb", ".css", ".scss",
|
|
20
|
+
".html", ".vue", ".php", ".rb", ".ex", ".exs", ".erl", ".hrl", ".clj",
|
|
21
|
+
".cljs", ".cljc", ".lua", ".sql", ".sh", ".ps1",
|
|
22
|
+
}
|
|
23
|
+
DEFAULT_EXCLUDES = [
|
|
24
|
+
"**/.git/**", "**/.vs/**", "**/.idea/**", "**/.vscode/**", "**/bin/**", "**/obj/**",
|
|
25
|
+
"**/node_modules/**", "**/dist/**", "**/build/**", "**/coverage/**", "**/generated/**",
|
|
26
|
+
"**/Generated/**", "**/vendor/**", "**/Vendor/**", "**/Migrations/**", "**/*.g.cs",
|
|
27
|
+
"**/*.generated.cs", "**/*.Designer.cs", "**/*.designer.cs", "**/*.min.js", "**/*.min.css",
|
|
28
|
+
]
|
|
29
|
+
COMMENT_PREFIXES = {
|
|
30
|
+
".cs": ["//"], ".cshtml": ["@*"], ".razor": ["@*", "//"], ".js": ["//"],
|
|
31
|
+
".jsx": ["//"], ".ts": ["//"], ".tsx": ["//"], ".py": ["#"], ".java": ["//"],
|
|
32
|
+
".kt": ["//"], ".kts": ["//"], ".scala": ["//"], ".go": ["//"], ".rs": ["//"],
|
|
33
|
+
".swift": ["//"], ".dart": ["//"], ".zig": ["//"], ".cpp": ["//"], ".c": ["//"],
|
|
34
|
+
".h": ["//"], ".hpp": ["//"], ".m": ["//"], ".mm": ["//"], ".fs": ["//"],
|
|
35
|
+
".fsx": ["//"], ".vb": ["'"], ".css": ["/*"], ".scss": ["//", "/*"],
|
|
36
|
+
".html": ["<!--"], ".vue": ["<!--"], ".php": ["//", "#"], ".rb": ["#"],
|
|
37
|
+
".ex": ["#"], ".exs": ["#"], ".erl": ["%"], ".hrl": ["%"], ".clj": [";"],
|
|
38
|
+
".cljs": [";"], ".cljc": [";"], ".lua": ["--"], ".sql": ["--"], ".sh": ["#"],
|
|
39
|
+
".ps1": ["#"],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class AllowedLargeFile:
|
|
45
|
+
path: str
|
|
46
|
+
reason: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ThresholdOverride:
|
|
51
|
+
match: list[str]
|
|
52
|
+
warn_at: int
|
|
53
|
+
fail_at: int
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Config:
|
|
58
|
+
enabled: bool
|
|
59
|
+
warn_at: int
|
|
60
|
+
fail_at: int
|
|
61
|
+
count_blank_lines: bool
|
|
62
|
+
count_comment_lines: bool
|
|
63
|
+
include_extensions: set[str]
|
|
64
|
+
exclude: list[str]
|
|
65
|
+
allowed_large_files: list[AllowedLargeFile]
|
|
66
|
+
overrides: list[ThresholdOverride]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def load_config(args: argparse.Namespace) -> Config:
|
|
70
|
+
document: dict[str, Any] = {}
|
|
71
|
+
config_path = args.config
|
|
72
|
+
if config_path:
|
|
73
|
+
path = Path(config_path)
|
|
74
|
+
if not path.exists():
|
|
75
|
+
raise FileNotFoundError(f"config file not found: {config_path}")
|
|
76
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
77
|
+
else:
|
|
78
|
+
auto = Path(".agent-tools/code-guard.config.json")
|
|
79
|
+
if auto.exists():
|
|
80
|
+
document = json.loads(auto.read_text(encoding="utf-8"))
|
|
81
|
+
if not isinstance(document, dict):
|
|
82
|
+
raise ValueError("configuration must be an object")
|
|
83
|
+
guards = document.get("guards", {})
|
|
84
|
+
if not isinstance(guards, dict):
|
|
85
|
+
raise ValueError("guards must be an object")
|
|
86
|
+
data = guards.get("loc", {})
|
|
87
|
+
if not isinstance(data, dict):
|
|
88
|
+
raise ValueError("guards.loc must be an object")
|
|
89
|
+
enabled = data.get("enabled", True)
|
|
90
|
+
if not isinstance(enabled, bool):
|
|
91
|
+
raise ValueError("guards.loc.enabled must be a boolean")
|
|
92
|
+
|
|
93
|
+
warn_at = args.warn if args.warn is not None else data.get("warnAt", DEFAULT_WARN_AT)
|
|
94
|
+
fail_at = args.fail if args.fail is not None else data.get("failAt", DEFAULT_FAIL_AT)
|
|
95
|
+
warn_at = parse_positive_integer(warn_at, "guards.loc.warnAt")
|
|
96
|
+
fail_at = parse_positive_integer(fail_at, "guards.loc.failAt")
|
|
97
|
+
if warn_at >= fail_at:
|
|
98
|
+
raise ValueError("guards.loc.warnAt must be lower than guards.loc.failAt")
|
|
99
|
+
extensions = data.get("includeExtensions", list(DEFAULT_INCLUDE_EXTENSIONS))
|
|
100
|
+
if not isinstance(extensions, list) or any(not isinstance(value, str) for value in extensions):
|
|
101
|
+
raise ValueError("guards.loc.includeExtensions must be an array of strings")
|
|
102
|
+
include_extensions = {normalise_extension(value) for value in extensions}
|
|
103
|
+
include_extensions.update(normalise_extension(value) for value in args.include)
|
|
104
|
+
exclude = data.get("exclude", DEFAULT_EXCLUDES)
|
|
105
|
+
if not isinstance(exclude, list) or any(not isinstance(value, str) for value in exclude):
|
|
106
|
+
raise ValueError("guards.loc.exclude must be an array of strings")
|
|
107
|
+
exclude = list(exclude) + args.exclude
|
|
108
|
+
count_blank = data.get("countBlankLines", False)
|
|
109
|
+
count_comments = data.get("countCommentLines", True)
|
|
110
|
+
if not isinstance(count_blank, bool) or not isinstance(count_comments, bool):
|
|
111
|
+
raise ValueError("guards.loc line-count options must be booleans")
|
|
112
|
+
return Config(
|
|
113
|
+
enabled, warn_at, fail_at, count_blank or args.count_blank_lines,
|
|
114
|
+
False if args.ignore_comment_lines else count_comments, include_extensions, exclude,
|
|
115
|
+
parse_allowed_large_files(data.get("allowedLargeFiles", [])),
|
|
116
|
+
parse_overrides(data.get("overrides", [])),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_allowed_large_files(value: Any) -> list[AllowedLargeFile]:
|
|
121
|
+
if not isinstance(value, list):
|
|
122
|
+
raise ValueError("guards.loc.allowedLargeFiles must be an array")
|
|
123
|
+
allowed = []
|
|
124
|
+
for index, item in enumerate(value):
|
|
125
|
+
location = f"guards.loc.allowedLargeFiles[{index}]"
|
|
126
|
+
if not isinstance(item, dict):
|
|
127
|
+
raise ValueError(f"{location} must be an object")
|
|
128
|
+
path = item.get("path")
|
|
129
|
+
reason = item.get("reason")
|
|
130
|
+
if not isinstance(path, str) or not path.strip():
|
|
131
|
+
raise ValueError(f"{location}.path must be a non-empty string")
|
|
132
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
133
|
+
raise ValueError(f"{location}.reason must be a non-empty string")
|
|
134
|
+
allowed.append(AllowedLargeFile(path.replace("\\", "/"), reason))
|
|
135
|
+
return allowed
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_overrides(value: Any) -> list[ThresholdOverride]:
|
|
139
|
+
if not isinstance(value, list):
|
|
140
|
+
raise ValueError("guards.loc.overrides must be an array")
|
|
141
|
+
overrides = []
|
|
142
|
+
for index, item in enumerate(value):
|
|
143
|
+
location = f"guards.loc.overrides[{index}]"
|
|
144
|
+
if not isinstance(item, dict):
|
|
145
|
+
raise ValueError(f"{location} must be an object")
|
|
146
|
+
patterns = item.get("match")
|
|
147
|
+
if not isinstance(patterns, list) or not patterns or any(
|
|
148
|
+
not isinstance(pattern, str) or not pattern.strip() for pattern in patterns
|
|
149
|
+
):
|
|
150
|
+
raise ValueError(f"{location}.match must be a non-empty array of non-empty strings")
|
|
151
|
+
warn_at = parse_positive_integer(item.get("warnAt"), f"{location}.warnAt")
|
|
152
|
+
fail_at = parse_positive_integer(item.get("failAt"), f"{location}.failAt")
|
|
153
|
+
if warn_at >= fail_at:
|
|
154
|
+
raise ValueError(f"{location}.warnAt must be lower than {location}.failAt")
|
|
155
|
+
overrides.append(ThresholdOverride([pattern.replace("\\", "/") for pattern in patterns], warn_at, fail_at))
|
|
156
|
+
return overrides
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def parse_positive_integer(value: Any, location: str) -> int:
|
|
160
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
161
|
+
raise ValueError(f"{location} must be a positive integer")
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def normalise_extension(value: str) -> str:
|
|
166
|
+
value = value.strip()
|
|
167
|
+
return value if not value or value.startswith(".") else f".{value}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def run(root: Path, config: Config, selected_files: tuple[Path, ...]) -> GuardResult:
|
|
171
|
+
if not config.enabled:
|
|
172
|
+
return GuardResult("loc", "pass", [])
|
|
173
|
+
files = [path for path in selected_files if should_include(path, config, root)]
|
|
174
|
+
findings = [evaluate(path, config, root) for path in sorted(set(files), key=lambda p: relative_path(p, root))]
|
|
175
|
+
state = "fail" if any(item.state == "fail" for item in findings) else (
|
|
176
|
+
"review" if any(item.state == "review" for item in findings) else "pass"
|
|
177
|
+
)
|
|
178
|
+
return GuardResult("loc", state, findings)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def should_include(path: Path, config: Config, root: Path) -> bool:
|
|
182
|
+
return path.suffix in config.include_extensions and not any(
|
|
183
|
+
matches_path_glob(relative_path(path, root), pattern) for pattern in config.exclude
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def evaluate(path: Path, config: Config, root: Path) -> Finding:
|
|
188
|
+
rel = relative_path(path, root)
|
|
189
|
+
counted = count_loc(path, config)
|
|
190
|
+
warn_at, fail_at, override_index = effective_thresholds(rel, config)
|
|
191
|
+
allowed = next((item for item in config.allowed_large_files if matches_path_glob(rel, item.path)), None)
|
|
192
|
+
if allowed and counted > warn_at:
|
|
193
|
+
native_status, state, reason = "exempt", "pass", allowed.reason
|
|
194
|
+
elif counted > fail_at:
|
|
195
|
+
native_status, state, reason = "fail", "fail", None
|
|
196
|
+
elif counted > warn_at:
|
|
197
|
+
native_status, state, reason = "warn", "review", None
|
|
198
|
+
else:
|
|
199
|
+
native_status, state, reason = "ok", "pass", None
|
|
200
|
+
return Finding(rel, state, native_status, counted, warn_at, fail_at, override_index, reason)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def count_loc(path: Path, config: Config) -> int:
|
|
204
|
+
count = 0
|
|
205
|
+
prefixes = COMMENT_PREFIXES.get(path.suffix, [])
|
|
206
|
+
with path.open("r", encoding="utf-8", errors="ignore") as handle:
|
|
207
|
+
for raw_line in handle:
|
|
208
|
+
stripped = raw_line.rstrip("\n\r").strip()
|
|
209
|
+
if not config.count_blank_lines and not stripped:
|
|
210
|
+
continue
|
|
211
|
+
if not config.count_comment_lines and is_simple_comment_line(stripped, prefixes, path.suffix):
|
|
212
|
+
continue
|
|
213
|
+
count += 1
|
|
214
|
+
return count
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def is_simple_comment_line(stripped: str, prefixes: list[str], extension: str) -> bool:
|
|
218
|
+
if not stripped or (extension == ".php" and stripped.startswith("#[")):
|
|
219
|
+
return False
|
|
220
|
+
return any(stripped.startswith(prefix) for prefix in prefixes)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def effective_thresholds(rel: str, config: Config) -> tuple[int, int, int | None]:
|
|
224
|
+
selected = None
|
|
225
|
+
for index, override in enumerate(config.overrides):
|
|
226
|
+
if any(matches_path_glob(rel, pattern) for pattern in override.match):
|
|
227
|
+
selected = (index, override)
|
|
228
|
+
if selected is None:
|
|
229
|
+
return config.warn_at, config.fail_at, None
|
|
230
|
+
index, override = selected
|
|
231
|
+
return override.warn_at, override.fail_at, index
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def relative_path(path: Path, root: Path) -> str:
|
|
235
|
+
return relative_or_absolute_path(path, root)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Markdown document physical-size review guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from ..reporting import reporting_path
|
|
12
|
+
from ..result_model import GuardResult, MarkdownDocumentFinding
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..markdown.facts import MarkdownFacts
|
|
16
|
+
|
|
17
|
+
DEFAULT_REVIEW_AT = 800
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Config:
|
|
22
|
+
enabled: bool
|
|
23
|
+
review_at: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_config(args: argparse.Namespace) -> Config:
|
|
27
|
+
document: dict[str, Any] = {}
|
|
28
|
+
if args.config:
|
|
29
|
+
path = Path(args.config)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
raise FileNotFoundError(f"config file not found: {args.config}")
|
|
32
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
else:
|
|
34
|
+
auto = Path(".agent-tools/code-guard.config.json")
|
|
35
|
+
if auto.exists():
|
|
36
|
+
document = json.loads(auto.read_text(encoding="utf-8"))
|
|
37
|
+
if not isinstance(document, dict):
|
|
38
|
+
raise ValueError("configuration must be an object")
|
|
39
|
+
guards = document.get("guards", {})
|
|
40
|
+
if not isinstance(guards, dict):
|
|
41
|
+
raise ValueError("guards must be an object")
|
|
42
|
+
data = guards.get("markdownDocumentSize")
|
|
43
|
+
if data is None:
|
|
44
|
+
return Config(True, DEFAULT_REVIEW_AT)
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise ValueError("guards.markdownDocumentSize must be an object")
|
|
47
|
+
enabled = data.get("enabled", True)
|
|
48
|
+
if not isinstance(enabled, bool):
|
|
49
|
+
raise ValueError("guards.markdownDocumentSize.enabled must be a boolean")
|
|
50
|
+
if not enabled:
|
|
51
|
+
return Config(False)
|
|
52
|
+
review_at = data.get("reviewAt", DEFAULT_REVIEW_AT)
|
|
53
|
+
if isinstance(review_at, bool) or not isinstance(review_at, int) or review_at <= 0:
|
|
54
|
+
raise ValueError("guards.markdownDocumentSize.reviewAt must be a positive integer")
|
|
55
|
+
return Config(True, review_at)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(root: Path, config: Config, facts: MarkdownFacts) -> GuardResult:
|
|
59
|
+
assert config.review_at is not None
|
|
60
|
+
findings = [MarkdownDocumentFinding(
|
|
61
|
+
reporting_path(fact.path, root), fact.physical_lines,
|
|
62
|
+
"review" if fact.physical_lines > config.review_at else "pass",
|
|
63
|
+
{"reviewAt": config.review_at},
|
|
64
|
+
) for fact in facts.documents]
|
|
65
|
+
findings.sort(key=lambda finding: finding.path)
|
|
66
|
+
return GuardResult("markdownDocumentSize", "review" if any(item.state == "review" for item in findings) else "pass", findings)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Markdown heading-delimited direct-section physical-size review guard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from ..reporting import reporting_path
|
|
12
|
+
from ..result_model import GuardResult, MarkdownSectionFinding
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..markdown.facts import MarkdownFacts
|
|
16
|
+
|
|
17
|
+
DEFAULT_REVIEW_AT = 200
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Config:
|
|
22
|
+
enabled: bool
|
|
23
|
+
review_at: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_config(args: argparse.Namespace) -> Config:
|
|
27
|
+
document: dict[str, Any] = {}
|
|
28
|
+
if args.config:
|
|
29
|
+
path = Path(args.config)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
raise FileNotFoundError(f"config file not found: {args.config}")
|
|
32
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
else:
|
|
34
|
+
auto = Path(".agent-tools/code-guard.config.json")
|
|
35
|
+
if auto.exists():
|
|
36
|
+
document = json.loads(auto.read_text(encoding="utf-8"))
|
|
37
|
+
if not isinstance(document, dict):
|
|
38
|
+
raise ValueError("configuration must be an object")
|
|
39
|
+
guards = document.get("guards", {})
|
|
40
|
+
if not isinstance(guards, dict):
|
|
41
|
+
raise ValueError("guards must be an object")
|
|
42
|
+
data = guards.get("markdownSectionSize")
|
|
43
|
+
if data is None:
|
|
44
|
+
return Config(True, DEFAULT_REVIEW_AT)
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise ValueError("guards.markdownSectionSize must be an object")
|
|
47
|
+
enabled = data.get("enabled", True)
|
|
48
|
+
if not isinstance(enabled, bool):
|
|
49
|
+
raise ValueError("guards.markdownSectionSize.enabled must be a boolean")
|
|
50
|
+
if not enabled:
|
|
51
|
+
return Config(False)
|
|
52
|
+
review_at = data.get("reviewAt", DEFAULT_REVIEW_AT)
|
|
53
|
+
if isinstance(review_at, bool) or not isinstance(review_at, int) or review_at <= 0:
|
|
54
|
+
raise ValueError("guards.markdownSectionSize.reviewAt must be a positive integer")
|
|
55
|
+
return Config(True, review_at)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(root: Path, config: Config, facts: MarkdownFacts) -> GuardResult:
|
|
59
|
+
assert config.review_at is not None
|
|
60
|
+
findings = [MarkdownSectionFinding(
|
|
61
|
+
reporting_path(document.path, root), section.heading, section.level,
|
|
62
|
+
section.start_line, section.end_line, section.physical_lines,
|
|
63
|
+
"review" if section.physical_lines > config.review_at else "pass", {"reviewAt": config.review_at},
|
|
64
|
+
) for document in facts.documents for section in document.sections]
|
|
65
|
+
findings.sort(key=lambda finding: (finding.path, finding.start_line, finding.end_line, finding.heading))
|
|
66
|
+
return GuardResult("markdownSectionSize", "review" if any(item.state == "review" for item in findings) else "pass", findings)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Structural nesting guard over shared provider-neutral control-flow facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from ..reporting import reporting_path
|
|
12
|
+
from ..result_model import CallableFinding, GuardResult
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ..analysis.facts import AnalysisFacts, CallableFact, ControlFlowFact, SourceRange
|
|
16
|
+
|
|
17
|
+
DEFAULT_REVIEW_AT = 4
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Config:
|
|
22
|
+
enabled: bool
|
|
23
|
+
review_at: int | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_config(args: argparse.Namespace) -> Config:
|
|
27
|
+
document: dict[str, Any] = {}
|
|
28
|
+
if args.config:
|
|
29
|
+
path = Path(args.config)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
raise FileNotFoundError(f"config file not found: {args.config}")
|
|
32
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
33
|
+
else:
|
|
34
|
+
auto = Path(".agent-tools/code-guard.config.json")
|
|
35
|
+
if auto.exists():
|
|
36
|
+
document = json.loads(auto.read_text(encoding="utf-8"))
|
|
37
|
+
if not isinstance(document, dict):
|
|
38
|
+
raise ValueError("configuration must be an object")
|
|
39
|
+
guards = document.get("guards", {})
|
|
40
|
+
if not isinstance(guards, dict):
|
|
41
|
+
raise ValueError("guards must be an object")
|
|
42
|
+
data = guards.get("nesting")
|
|
43
|
+
if data is None:
|
|
44
|
+
return Config(True, DEFAULT_REVIEW_AT)
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise ValueError("guards.nesting must be an object")
|
|
47
|
+
enabled = data.get("enabled", True)
|
|
48
|
+
if not isinstance(enabled, bool):
|
|
49
|
+
raise ValueError("guards.nesting.enabled must be a boolean")
|
|
50
|
+
if not enabled:
|
|
51
|
+
return Config(False)
|
|
52
|
+
review_at = data.get("reviewAt", DEFAULT_REVIEW_AT)
|
|
53
|
+
if isinstance(review_at, bool) or not isinstance(review_at, int) or review_at <= 0:
|
|
54
|
+
raise ValueError("guards.nesting.reviewAt must be a positive integer")
|
|
55
|
+
return Config(True, review_at)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def run(root: Path, config: Config, analysis_facts: AnalysisFacts) -> GuardResult:
|
|
59
|
+
if not config.enabled:
|
|
60
|
+
return GuardResult("nesting", "pass", [])
|
|
61
|
+
controls_by_callable: dict[object, list[ControlFlowFact]] = {}
|
|
62
|
+
for control in analysis_facts.controls:
|
|
63
|
+
controls_by_callable.setdefault(control.callable_key, []).append(control)
|
|
64
|
+
findings = [
|
|
65
|
+
evaluate(root, config, callable_fact, controls_by_callable.get(callable_fact.key, []))
|
|
66
|
+
for callable_fact in analysis_facts.callables
|
|
67
|
+
]
|
|
68
|
+
findings.sort(key=lambda finding: (finding.path, finding.start_line, finding.end_line, finding.callable))
|
|
69
|
+
state = "review" if any(finding.state == "review" for finding in findings) else "pass"
|
|
70
|
+
return GuardResult("nesting", state, findings)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def evaluate(
|
|
74
|
+
root: Path,
|
|
75
|
+
config: Config,
|
|
76
|
+
callable_fact: CallableFact,
|
|
77
|
+
controls: list[ControlFlowFact] | tuple[ControlFlowFact, ...],
|
|
78
|
+
) -> CallableFinding:
|
|
79
|
+
assert config.review_at is not None
|
|
80
|
+
depth, deepest_line = _maximum_depth(controls)
|
|
81
|
+
return CallableFinding(
|
|
82
|
+
path=reporting_path(callable_fact.path, root),
|
|
83
|
+
callable=callable_fact.identity,
|
|
84
|
+
start_line=callable_fact.source_range.start_line,
|
|
85
|
+
end_line=callable_fact.source_range.end_line,
|
|
86
|
+
measured=depth,
|
|
87
|
+
state="review" if depth > config.review_at else "pass",
|
|
88
|
+
thresholds={"reviewAt": config.review_at},
|
|
89
|
+
details={"deepestLine": deepest_line} if deepest_line is not None else None,
|
|
90
|
+
embedded_language=callable_fact.embedded_language,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _maximum_depth(controls: list[ControlFlowFact] | tuple[ControlFlowFact, ...]) -> tuple[int, int | None]:
|
|
95
|
+
depth_by_range: dict[SourceRange, int] = {}
|
|
96
|
+
maximum = 0
|
|
97
|
+
deepest_line: int | None = None
|
|
98
|
+
ordered = sorted(
|
|
99
|
+
controls,
|
|
100
|
+
key=lambda fact: (fact.source_range.start.byte_offset, fact.source_range.end.byte_offset),
|
|
101
|
+
)
|
|
102
|
+
for fact in ordered:
|
|
103
|
+
parent_depth = depth_by_range.get(fact.parent_control_range, 0)
|
|
104
|
+
depth = parent_depth + int(fact.increases_nesting)
|
|
105
|
+
depth_by_range[fact.source_range] = depth
|
|
106
|
+
if depth > maximum:
|
|
107
|
+
maximum = depth
|
|
108
|
+
deepest_line = fact.source_range.start_line
|
|
109
|
+
return maximum, deepest_line
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Immutable Markdown structure facts from a bounded standard-library scan."""
|
|
2
|
+
|
|
3
|
+
from .facts import MarkdownDocumentFact, MarkdownFacts, MarkdownSectionFact
|
|
4
|
+
from .scanner import analyze_files, scan_text
|
|
5
|
+
|
|
6
|
+
__all__ = ["MarkdownDocumentFact", "MarkdownFacts", "MarkdownSectionFact", "analyze_files", "scan_text"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Concrete immutable facts for Markdown size guards."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class MarkdownSectionFact:
|
|
11
|
+
heading: str
|
|
12
|
+
level: int
|
|
13
|
+
start_line: int
|
|
14
|
+
end_line: int
|
|
15
|
+
physical_lines: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class MarkdownDocumentFact:
|
|
20
|
+
path: Path
|
|
21
|
+
physical_lines: int
|
|
22
|
+
sections: tuple[MarkdownSectionFact, ...]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class MarkdownFacts:
|
|
27
|
+
documents: tuple[MarkdownDocumentFact, ...]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Bounded CommonMark-informed scanner for admitted Markdown size facts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .facts import MarkdownDocumentFact, MarkdownFacts, MarkdownSectionFact
|
|
10
|
+
|
|
11
|
+
_ATX = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*?)|[ \t]*)$")
|
|
12
|
+
_SETEXT = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
|
|
13
|
+
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
14
|
+
_BLOCK_PREFIX = re.compile(r"^ {0,3}(?:>|[-+*][ \t]+|\d{1,9}[.)][ \t]+|<)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class _Heading:
|
|
19
|
+
text: str
|
|
20
|
+
level: int
|
|
21
|
+
start_line: int
|
|
22
|
+
end_line: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class _Fence:
|
|
27
|
+
marker: str
|
|
28
|
+
length: int
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def analyze_files(files: tuple[Path, ...] | list[Path]) -> MarkdownFacts:
|
|
32
|
+
applicable = sorted(
|
|
33
|
+
{path.resolve() for path in files if path.suffix.lower() == ".md"},
|
|
34
|
+
key=lambda path: path.as_posix(),
|
|
35
|
+
)
|
|
36
|
+
return MarkdownFacts(tuple(scan_text(path, path.read_text(encoding="utf-8")) for path in applicable))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def scan_text(path: Path, text: str) -> MarkdownDocumentFact:
|
|
40
|
+
lines = text.splitlines()
|
|
41
|
+
headings = _scan_headings(lines)
|
|
42
|
+
sections = []
|
|
43
|
+
for index, heading in enumerate(headings):
|
|
44
|
+
end_line = headings[index + 1].start_line - 1 if index + 1 < len(headings) else len(lines)
|
|
45
|
+
sections.append(MarkdownSectionFact(
|
|
46
|
+
heading.text, heading.level, heading.start_line, end_line,
|
|
47
|
+
end_line - heading.start_line + 1,
|
|
48
|
+
))
|
|
49
|
+
return MarkdownDocumentFact(path, len(lines), tuple(sections))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _scan_headings(lines: list[str]) -> list[_Heading]:
|
|
53
|
+
headings: list[_Heading] = []
|
|
54
|
+
fence: _Fence | None = None
|
|
55
|
+
for index, line in enumerate(lines):
|
|
56
|
+
line_number = index + 1
|
|
57
|
+
if fence:
|
|
58
|
+
if _closes_fence(line, fence):
|
|
59
|
+
fence = None
|
|
60
|
+
continue
|
|
61
|
+
opened = _opens_fence(line)
|
|
62
|
+
if opened:
|
|
63
|
+
fence = opened
|
|
64
|
+
continue
|
|
65
|
+
atx = _atx_heading(line, line_number)
|
|
66
|
+
if atx:
|
|
67
|
+
headings.append(atx)
|
|
68
|
+
continue
|
|
69
|
+
setext = _setext_heading(lines, index)
|
|
70
|
+
if setext and (not headings or headings[-1].end_line != line_number - 1):
|
|
71
|
+
headings.append(setext)
|
|
72
|
+
return headings
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _opens_fence(line: str) -> _Fence | None:
|
|
76
|
+
match = _FENCE_OPEN.match(line)
|
|
77
|
+
if not match:
|
|
78
|
+
return None
|
|
79
|
+
run, info = match.groups()
|
|
80
|
+
if run[0] == "`" and "`" in info:
|
|
81
|
+
return None
|
|
82
|
+
return _Fence(run[0], len(run))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _closes_fence(line: str, fence: _Fence) -> bool:
|
|
86
|
+
return bool(re.match(rf"^ {{0,3}}{re.escape(fence.marker)}{{{fence.length},}}[ \t]*$", line))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _atx_heading(line: str, line_number: int) -> _Heading | None:
|
|
90
|
+
match = _ATX.match(line)
|
|
91
|
+
if not match:
|
|
92
|
+
return None
|
|
93
|
+
hashes, raw_text = match.groups()
|
|
94
|
+
text = re.sub(r"[ \t]+#+[ \t]*$", "", raw_text or "").strip()
|
|
95
|
+
return _Heading(text, len(hashes), line_number, line_number)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _setext_heading(lines: list[str], index: int) -> _Heading | None:
|
|
99
|
+
match = _SETEXT.match(lines[index]) if index else None
|
|
100
|
+
if not match:
|
|
101
|
+
return None
|
|
102
|
+
title = lines[index - 1]
|
|
103
|
+
if index >= 2 and lines[index - 2].strip():
|
|
104
|
+
return None
|
|
105
|
+
if not title.strip() or len(title) - len(title.lstrip(" ")) >= 4:
|
|
106
|
+
return None
|
|
107
|
+
if _ATX.match(title) or _FENCE_OPEN.match(title) or _BLOCK_PREFIX.match(title):
|
|
108
|
+
return None
|
|
109
|
+
return _Heading(title.strip(), 1 if match.group(1)[0] == "=" else 2, index, index + 1)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Shared normalized path matching for common scope and guard policies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def matches_path_glob(path: str, pattern: str) -> bool:
|
|
10
|
+
normalised_path = path.replace("\\", "/").removeprefix("./")
|
|
11
|
+
normalised_pattern = pattern.replace("\\", "/").removeprefix("./")
|
|
12
|
+
if normalised_path == normalised_pattern:
|
|
13
|
+
return True
|
|
14
|
+
candidates = [normalised_pattern]
|
|
15
|
+
while normalised_pattern.startswith("**/"):
|
|
16
|
+
normalised_pattern = normalised_pattern[3:]
|
|
17
|
+
candidates.append(normalised_pattern)
|
|
18
|
+
return any(fnmatch.fnmatch(normalised_path, candidate) for candidate in candidates)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def relative_or_absolute_path(path: Path, root: Path) -> str:
|
|
22
|
+
try:
|
|
23
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
24
|
+
except ValueError:
|
|
25
|
+
return path.resolve().as_posix()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Small shared helpers for stable result reporting."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def reporting_path(path: Path, root: Path) -> str:
|
|
7
|
+
resolved = path.resolve()
|
|
8
|
+
try:
|
|
9
|
+
return resolved.relative_to(root.resolve()).as_posix()
|
|
10
|
+
except ValueError:
|
|
11
|
+
return resolved.as_posix()
|