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,90 @@
1
+ """Reject unsupported configuration properties before scope or guard work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ ROOT_KEYS = {"version", "scope", "guards"}
10
+ SCOPE_KEYS = {"exclude"}
11
+ GUARD_KEYS = {
12
+ "loc",
13
+ "callableSize",
14
+ "nesting",
15
+ "cyclomaticComplexity",
16
+ "markdownDocumentSize",
17
+ "markdownSectionSize",
18
+ }
19
+ REVIEW_GUARD_NAMES = (
20
+ "callableSize",
21
+ "nesting",
22
+ "cyclomaticComplexity",
23
+ "markdownDocumentSize",
24
+ "markdownSectionSize",
25
+ )
26
+ REVIEW_GUARD_KEYS = {"enabled", "reviewAt"}
27
+ LOC_KEYS = {
28
+ "enabled",
29
+ "warnAt",
30
+ "failAt",
31
+ "countBlankLines",
32
+ "countCommentLines",
33
+ "includeExtensions",
34
+ "exclude",
35
+ "allowedLargeFiles",
36
+ "overrides",
37
+ }
38
+ LOC_ALLOWED_LARGE_FILE_KEYS = {"path", "reason"}
39
+ LOC_OVERRIDE_KEYS = {"match", "warnAt", "failAt"}
40
+
41
+
42
+ def validate_configuration(config: str | None, start: Path) -> None:
43
+ """Load the configured document once and validate its known property names."""
44
+ path = Path(config) if config else start / ".agent-tools" / "code-guard.config.json"
45
+ if config and not path.exists():
46
+ raise FileNotFoundError(f"config file not found: {config}")
47
+ if not path.exists():
48
+ return
49
+ document = json.loads(path.read_text(encoding="utf-8"))
50
+ if not isinstance(document, dict):
51
+ raise ValueError("configuration must be an object")
52
+ _reject_unknown(document, ROOT_KEYS, "")
53
+ _validate_object_keys(document.get("scope"), SCOPE_KEYS, "scope")
54
+
55
+ guards = document.get("guards")
56
+ if not isinstance(guards, dict):
57
+ return
58
+ _reject_unknown(guards, GUARD_KEYS, "guards")
59
+ for guard_name in REVIEW_GUARD_NAMES:
60
+ _validate_object_keys(guards.get(guard_name), REVIEW_GUARD_KEYS, f"guards.{guard_name}")
61
+ loc = guards.get("loc")
62
+ if not isinstance(loc, dict):
63
+ return
64
+ _reject_unknown(loc, LOC_KEYS, "guards.loc")
65
+ _validate_items(
66
+ loc.get("allowedLargeFiles"),
67
+ LOC_ALLOWED_LARGE_FILE_KEYS,
68
+ "guards.loc.allowedLargeFiles",
69
+ )
70
+ _validate_items(loc.get("overrides"), LOC_OVERRIDE_KEYS, "guards.loc.overrides")
71
+
72
+
73
+ def _validate_object_keys(value: Any, allowed: set[str], path: str) -> None:
74
+ if isinstance(value, dict):
75
+ _reject_unknown(value, allowed, path)
76
+
77
+
78
+ def _validate_items(value: Any, allowed: set[str], path: str) -> None:
79
+ if not isinstance(value, list):
80
+ return
81
+ for index, item in enumerate(value):
82
+ if isinstance(item, dict):
83
+ _reject_unknown(item, allowed, f"{path}[{index}]")
84
+
85
+
86
+ def _reject_unknown(value: dict[str, Any], allowed: set[str], path: str) -> None:
87
+ unknown = sorted(key for key in value if key not in allowed)
88
+ if unknown:
89
+ property_path = f"{path}.{unknown[0]}" if path else unknown[0]
90
+ raise ValueError(f"unknown configuration property: {property_path}")
@@ -0,0 +1,228 @@
1
+ """Resolve caller or Git scope before any guard applies its own filtering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Protocol
11
+
12
+ from .path_matching import matches_path_glob, relative_or_absolute_path
13
+
14
+ BUILTIN_PRUNED_DIRECTORIES = {".git", "node_modules", "bin", "obj"}
15
+
16
+
17
+ class SelectionArgs(Protocol):
18
+ paths: list[str]
19
+ changed_only: bool
20
+ staged: bool
21
+ base_ref: str | None
22
+ config: str | None
23
+ scope_exclude: list[str]
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ResolvedScope:
28
+ root: Path
29
+ files: tuple[Path, ...]
30
+
31
+
32
+ def find_repo_root(start: Path) -> Path | None:
33
+ """Return the enclosing Git root, without inventing one when Git is absent."""
34
+ try:
35
+ result = subprocess.run(
36
+ ["git", "rev-parse", "--show-toplevel"], cwd=start, check=True,
37
+ text=True, capture_output=True,
38
+ )
39
+ return Path(result.stdout.strip()).resolve()
40
+ except Exception:
41
+ return None
42
+
43
+
44
+ def resolve_scope(args: SelectionArgs, start: Path) -> ResolvedScope:
45
+ """Resolve and normalize the complete file scope shared by all guards."""
46
+ working_root = start.resolve()
47
+ git_root = find_repo_root(working_root)
48
+ root = git_root or working_root
49
+ validate_selection_args(args, git_root)
50
+ paths = resolve_explicit_paths(args.paths, working_root)
51
+
52
+ if args.base_ref is not None:
53
+ candidates = git_base_files(git_root, args.base_ref)
54
+ files = bound_git_candidates(existing_files(candidates), paths)
55
+ elif args.changed_only or args.staged:
56
+ candidates = git_files(git_root, staged=args.staged)
57
+ files = bound_git_candidates(existing_files(candidates), paths)
58
+ else:
59
+ files = existing_files(expand_paths(paths, git_root))
60
+
61
+ normalized = tuple(dict.fromkeys(path.resolve() for path in files))
62
+ exclusions = load_scope_exclusions(args, working_root)
63
+ filtered = tuple(
64
+ path for path in normalized
65
+ if not any(matches_path_glob(relative_or_absolute_path(path, root), pattern) for pattern in exclusions)
66
+ )
67
+ return ResolvedScope(root, filtered)
68
+
69
+
70
+ def resolve_explicit_paths(values: list[str], working_root: Path) -> list[Path]:
71
+ """Resolve and validate positional paths against the caller's working directory."""
72
+ paths = [Path(value) if Path(value).is_absolute() else working_root / value for value in values]
73
+ missing = [value for value, path in zip(values, paths) if not path.exists()]
74
+ if missing:
75
+ raise FileNotFoundError(f"explicit path does not exist: {missing[0]}")
76
+ directory_links = [value for value, path in zip(values, paths) if path.is_symlink() and path.is_dir()]
77
+ if directory_links:
78
+ raise ValueError(
79
+ f"explicit directory symlink is not recursively traversed: {directory_links[0]}"
80
+ )
81
+ return paths
82
+
83
+
84
+ def bound_git_candidates(candidates: list[Path], bounds: list[Path]) -> list[Path]:
85
+ """Intersect Git-selected files with the union of positional file/directory bounds."""
86
+ normalized_bounds = [(path.resolve(), path.is_dir()) for path in bounds]
87
+ return [
88
+ candidate for candidate in candidates
89
+ if any(
90
+ is_within(candidate, bound) if is_directory else candidate.resolve() == bound
91
+ for bound, is_directory in normalized_bounds
92
+ )
93
+ ]
94
+
95
+
96
+ def load_scope_exclusions(args: SelectionArgs, start: Path) -> list[str]:
97
+ explicit_config = getattr(args, "config", None)
98
+ config_path = Path(explicit_config) if explicit_config else start / ".agent-tools" / "code-guard.config.json"
99
+ if explicit_config and not config_path.exists():
100
+ raise FileNotFoundError(f"config file not found: {explicit_config}")
101
+ document = json.loads(config_path.read_text(encoding="utf-8")) if config_path.exists() else {}
102
+ if not isinstance(document, dict):
103
+ raise ValueError("configuration must be an object")
104
+ scope = document.get("scope", {})
105
+ if not isinstance(scope, dict):
106
+ raise ValueError("scope must be an object")
107
+ exclude = scope.get("exclude", [])
108
+ if not isinstance(exclude, list) or any(not isinstance(pattern, str) for pattern in exclude):
109
+ raise ValueError("scope.exclude must be an array of strings")
110
+ combined = [*exclude, *getattr(args, "scope_exclude", [])]
111
+ if any(not isinstance(pattern, str) or not pattern.strip() for pattern in combined):
112
+ raise ValueError("scope.exclude patterns must be non-empty strings")
113
+ return combined
114
+
115
+
116
+ def validate_selection_args(args: SelectionArgs, git_root: Path | None) -> None:
117
+ """Validate the runner-level scope independently of enabled guards."""
118
+ has_base_ref = args.base_ref is not None
119
+ if sum((args.changed_only, args.staged, has_base_ref)) > 1:
120
+ raise ValueError("use only one file-selection mode: --changed-only, --staged, or --base-ref")
121
+ if has_base_ref and not args.base_ref.strip():
122
+ raise ValueError("--base-ref must not be empty")
123
+ if (args.changed_only or args.staged or has_base_ref) and git_root is None:
124
+ raise RuntimeError("Git file-selection mode requires a Git repository")
125
+ if has_base_ref:
126
+ validate_base_ref(git_root, args.base_ref)
127
+
128
+
129
+ def validate_base_ref(root: Path, base_ref: str) -> None:
130
+ try:
131
+ subprocess.run(
132
+ ["git", "merge-base", base_ref, "HEAD"], cwd=root, check=True,
133
+ capture_output=True,
134
+ )
135
+ except subprocess.CalledProcessError as exc:
136
+ detail = os.fsdecode(exc.stderr).strip()
137
+ message = f"unable to compare base ref {base_ref!r} with HEAD"
138
+ raise RuntimeError(f"{message}: {detail}" if detail else message) from exc
139
+
140
+
141
+ def git_files(root: Path, staged: bool) -> list[Path]:
142
+ has_head = subprocess.run(
143
+ ["git", "rev-parse", "--verify", "--quiet", "HEAD"], cwd=root,
144
+ check=False, capture_output=True,
145
+ ).returncode == 0
146
+ diff_target = ["--cached"] if staged or not has_head else ["HEAD"]
147
+ result = subprocess.run(
148
+ ["git", "diff", *diff_target, "--name-only", "--diff-filter=ACMR", "-z"],
149
+ cwd=root, check=True, capture_output=True,
150
+ )
151
+ files = [root / os.fsdecode(path) for path in result.stdout.split(b"\0") if path]
152
+ if not staged:
153
+ untracked = subprocess.run(
154
+ ["git", "ls-files", "--others", "--exclude-standard", "-z"],
155
+ cwd=root, check=True, capture_output=True,
156
+ )
157
+ files.extend(root / os.fsdecode(path) for path in untracked.stdout.split(b"\0") if path)
158
+ return files
159
+
160
+
161
+ def git_base_files(root: Path, base_ref: str) -> list[Path]:
162
+ try:
163
+ result = subprocess.run(
164
+ ["git", "diff", "--name-only", "--diff-filter=ACMR", "-z", f"{base_ref}...HEAD", "--"],
165
+ cwd=root, check=True, capture_output=True,
166
+ )
167
+ except subprocess.CalledProcessError as exc:
168
+ detail = os.fsdecode(exc.stderr).strip()
169
+ message = f"unable to compare base ref {base_ref!r} with HEAD"
170
+ raise RuntimeError(f"{message}: {detail}" if detail else message) from exc
171
+ return [root / os.fsdecode(path) for path in result.stdout.split(b"\0") if path]
172
+
173
+
174
+ def expand_paths(paths: list[Path], git_root: Path | None) -> list[Path]:
175
+ files: list[Path] = []
176
+ for path in paths:
177
+ if path.is_file():
178
+ files.append(path)
179
+ elif path.is_dir():
180
+ if git_root is not None and is_within(path, git_root):
181
+ files.extend(git_directory_files(git_root, path))
182
+ else:
183
+ files.extend(walk_directory_files(path))
184
+ return files
185
+
186
+
187
+ def git_directory_files(root: Path, directory: Path) -> list[Path]:
188
+ relative = directory.resolve().relative_to(root.resolve())
189
+ pathspec = "." if not relative.parts else relative.as_posix()
190
+ result = subprocess.run(
191
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", pathspec],
192
+ cwd=root, check=True, capture_output=True,
193
+ )
194
+ selected = [root / os.fsdecode(value) for value in result.stdout.split(b"\0") if value]
195
+ return [path for path in selected if not path.is_symlink() and not has_pruned_directory(path, root)]
196
+
197
+
198
+ def walk_directory_files(directory: Path) -> list[Path]:
199
+ files: list[Path] = []
200
+ for current_root, dir_names, file_names in os.walk(directory):
201
+ current = Path(current_root)
202
+ dir_names[:] = sorted(
203
+ name for name in dir_names
204
+ if name not in BUILTIN_PRUNED_DIRECTORIES and not (current / name).is_symlink()
205
+ )
206
+ files.extend(
207
+ current / name for name in sorted(file_names)
208
+ if not (current / name).is_symlink()
209
+ )
210
+ return files
211
+
212
+
213
+ def has_pruned_directory(path: Path, root: Path) -> bool:
214
+ relative = path.relative_to(root)
215
+ return any(part in BUILTIN_PRUNED_DIRECTORIES for part in relative.parts[:-1])
216
+
217
+
218
+ def is_within(path: Path, root: Path) -> bool:
219
+ try:
220
+ path.resolve().relative_to(root.resolve())
221
+ return True
222
+ except ValueError:
223
+ return False
224
+
225
+
226
+ def existing_files(paths: list[Path]) -> list[Path]:
227
+ """Ignore absent Git-derived entries while retaining every existing artifact."""
228
+ return [path for path in paths if path.exists() and path.is_file()]
@@ -0,0 +1 @@
1
+ """Internal guard implementations for Agent Code Guard."""
@@ -0,0 +1,79 @@
1
+ """Callable physical LOC guard over shared provider-neutral analysis 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
16
+
17
+ DEFAULT_REVIEW_AT = 80
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("callableSize")
43
+ if data is None:
44
+ return Config(True, DEFAULT_REVIEW_AT)
45
+ if not isinstance(data, dict):
46
+ raise ValueError("guards.callableSize must be an object")
47
+ enabled = data.get("enabled", True)
48
+ if not isinstance(enabled, bool):
49
+ raise ValueError("guards.callableSize.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.callableSize.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("callableSize", "pass", [])
61
+ findings = [evaluate(root, config, fact) for fact in analysis_facts.callables]
62
+ findings.sort(key=lambda finding: (finding.path, finding.start_line, finding.end_line, finding.callable))
63
+ state = "review" if any(finding.state == "review" for finding in findings) else "pass"
64
+ return GuardResult("callableSize", state, findings)
65
+
66
+
67
+ def evaluate(root: Path, config: Config, fact: CallableFact) -> CallableFinding:
68
+ assert config.review_at is not None
69
+ measured = fact.source_range.physical_loc
70
+ return CallableFinding(
71
+ path=reporting_path(fact.path, root),
72
+ callable=fact.identity,
73
+ start_line=fact.source_range.start_line,
74
+ end_line=fact.source_range.end_line,
75
+ measured=measured,
76
+ state="review" if measured > config.review_at else "pass",
77
+ thresholds={"reviewAt": config.review_at},
78
+ embedded_language=fact.embedded_language,
79
+ )
@@ -0,0 +1,94 @@
1
+ """Cyclomatic complexity guard over shared normalized decision facts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from collections import Counter
7
+ import json
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from ..reporting import reporting_path
13
+ from ..result_model import CallableFinding, GuardResult
14
+
15
+ if TYPE_CHECKING:
16
+ from ..analysis.facts import AnalysisFacts, CallableFact, DecisionFact
17
+
18
+ DEFAULT_REVIEW_AT = 15
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Config:
23
+ enabled: bool
24
+ review_at: int | None = None
25
+
26
+
27
+ def load_config(args: argparse.Namespace) -> Config:
28
+ document: dict[str, Any] = {}
29
+ if args.config:
30
+ path = Path(args.config)
31
+ if not path.exists():
32
+ raise FileNotFoundError(f"config file not found: {args.config}")
33
+ document = json.loads(path.read_text(encoding="utf-8"))
34
+ else:
35
+ auto = Path(".agent-tools/code-guard.config.json")
36
+ if auto.exists():
37
+ document = json.loads(auto.read_text(encoding="utf-8"))
38
+ if not isinstance(document, dict):
39
+ raise ValueError("configuration must be an object")
40
+ guards = document.get("guards", {})
41
+ if not isinstance(guards, dict):
42
+ raise ValueError("guards must be an object")
43
+ data = guards.get("cyclomaticComplexity")
44
+ if data is None:
45
+ return Config(True, DEFAULT_REVIEW_AT)
46
+ if not isinstance(data, dict):
47
+ raise ValueError("guards.cyclomaticComplexity must be an object")
48
+ enabled = data.get("enabled", True)
49
+ if not isinstance(enabled, bool):
50
+ raise ValueError("guards.cyclomaticComplexity.enabled must be a boolean")
51
+ if not enabled:
52
+ return Config(False)
53
+ review_at = data.get("reviewAt", DEFAULT_REVIEW_AT)
54
+ if isinstance(review_at, bool) or not isinstance(review_at, int) or review_at <= 0:
55
+ raise ValueError("guards.cyclomaticComplexity.reviewAt must be a positive integer")
56
+ return Config(True, review_at)
57
+
58
+
59
+ def run(root: Path, config: Config, analysis_facts: AnalysisFacts) -> GuardResult:
60
+ if not config.enabled:
61
+ return GuardResult("complexity", "pass", [])
62
+ decisions_by_callable: dict[object, list[DecisionFact]] = {}
63
+ for decision in analysis_facts.decisions:
64
+ decisions_by_callable.setdefault(decision.callable_key, []).append(decision)
65
+ findings = [
66
+ evaluate(root, config, callable_fact, decisions_by_callable.get(callable_fact.key, []))
67
+ for callable_fact in analysis_facts.callables
68
+ ]
69
+ findings.sort(key=lambda finding: (finding.path, finding.start_line, finding.end_line, finding.callable))
70
+ state = "review" if any(finding.state == "review" for finding in findings) else "pass"
71
+ return GuardResult("complexity", state, findings)
72
+
73
+
74
+ def evaluate(
75
+ root: Path,
76
+ config: Config,
77
+ callable_fact: CallableFact,
78
+ decisions: list[DecisionFact] | tuple[DecisionFact, ...],
79
+ ) -> CallableFinding:
80
+ assert config.review_at is not None
81
+ counts = Counter(decision.category for decision in decisions)
82
+ breakdown = {category: counts[category] for category in sorted(counts) if counts[category]}
83
+ measured = 1 + len(decisions)
84
+ return CallableFinding(
85
+ path=reporting_path(callable_fact.path, root),
86
+ callable=callable_fact.identity,
87
+ start_line=callable_fact.source_range.start_line,
88
+ end_line=callable_fact.source_range.end_line,
89
+ measured=measured,
90
+ state="review" if measured > config.review_at else "pass",
91
+ thresholds={"reviewAt": config.review_at},
92
+ details={"boundaryKind": callable_fact.boundary_kind, "decisions": breakdown},
93
+ embedded_language=callable_fact.embedded_language,
94
+ )