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.
Files changed (40) hide show
  1. agent_code_guard/__init__.py +1 -0
  2. agent_code_guard/analysis/__init__.py +13 -0
  3. agent_code_guard/analysis/adapters.py +608 -0
  4. agent_code_guard/analysis/errors.py +13 -0
  5. agent_code_guard/analysis/facts.py +101 -0
  6. agent_code_guard/analysis/language_specs.py +82 -0
  7. agent_code_guard/analysis/pipeline.py +37 -0
  8. agent_code_guard/analysis/provider.py +45 -0
  9. agent_code_guard/analysis/regions.py +108 -0
  10. agent_code_guard/code_guard.py +236 -0
  11. agent_code_guard/config_validation.py +90 -0
  12. agent_code_guard/file_selection.py +228 -0
  13. agent_code_guard/guards/__init__.py +1 -0
  14. agent_code_guard/guards/callable_size.py +79 -0
  15. agent_code_guard/guards/complexity.py +94 -0
  16. agent_code_guard/guards/loc.py +235 -0
  17. agent_code_guard/guards/markdown_document_size.py +66 -0
  18. agent_code_guard/guards/markdown_section_size.py +66 -0
  19. agent_code_guard/guards/nesting.py +109 -0
  20. agent_code_guard/markdown/__init__.py +6 -0
  21. agent_code_guard/markdown/facts.py +27 -0
  22. agent_code_guard/markdown/scanner.py +109 -0
  23. agent_code_guard/path_matching.py +25 -0
  24. agent_code_guard/reporting.py +11 -0
  25. agent_code_guard/result_model.py +128 -0
  26. agent_code_guard/skill_distribution.py +96 -0
  27. agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/LICENSE.txt +21 -0
  28. agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/SKILL.md +138 -0
  29. agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/agents/openai.yaml +8 -0
  30. agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/callable-size-policy.md +39 -0
  31. agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/complexity-policy.md +39 -0
  32. agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/loc-policy.md +40 -0
  33. agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/markdown-size-policy.md +16 -0
  34. agent_code_guard-0.1.0.data/data/share/agent-code-guard/skill/references/nesting-policy.md +37 -0
  35. agent_code_guard-0.1.0.dist-info/METADATA +206 -0
  36. agent_code_guard-0.1.0.dist-info/RECORD +40 -0
  37. agent_code_guard-0.1.0.dist-info/WHEEL +5 -0
  38. agent_code_guard-0.1.0.dist-info/entry_points.txt +2 -0
  39. agent_code_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
  40. 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()