refactorai-core 3.0.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 (45) hide show
  1. refactor_core/__init__.py +27 -0
  2. refactor_core/apply.py +252 -0
  3. refactor_core/budgeting.py +153 -0
  4. refactor_core/capabilities.py +56 -0
  5. refactor_core/compliance/__init__.py +34 -0
  6. refactor_core/compliance/detectors.py +109 -0
  7. refactor_core/compliance/evidence.py +45 -0
  8. refactor_core/compliance/loader.py +233 -0
  9. refactor_core/compliance/models.py +68 -0
  10. refactor_core/compliance/packs/__init__.py +2 -0
  11. refactor_core/compliance/packs/hipaa-ephi.json +55 -0
  12. refactor_core/compliance/packs/iso27001-sdlc.json +62 -0
  13. refactor_core/compliance/packs/pci-payments.json +63 -0
  14. refactor_core/compliance/packs/privacy-gdpr.json +71 -0
  15. refactor_core/compliance/packs/soc2-core.json +63 -0
  16. refactor_core/compliance/projection.py +252 -0
  17. refactor_core/compliance_settings.py +173 -0
  18. refactor_core/constitution.py +354 -0
  19. refactor_core/gate.py +48 -0
  20. refactor_core/indexing.py +225 -0
  21. refactor_core/model_catalog.py +127 -0
  22. refactor_core/models.py +69 -0
  23. refactor_core/op_classifier.py +243 -0
  24. refactor_core/op_feasibility.py +237 -0
  25. refactor_core/paths.py +47 -0
  26. refactor_core/refactor_ops.py +152 -0
  27. refactor_core/refactor_requests.py +858 -0
  28. refactor_core/rules/__init__.py +19 -0
  29. refactor_core/rules/injection.py +124 -0
  30. refactor_core/rules/loader.py +91 -0
  31. refactor_core/rules/matcher.py +41 -0
  32. refactor_core/rules/models.py +46 -0
  33. refactor_core/rules/resolver.py +150 -0
  34. refactor_core/rules/system_rules.json +170 -0
  35. refactor_core/security/__init__.py +35 -0
  36. refactor_core/security/finding_merge.py +159 -0
  37. refactor_core/security/semgrep_parser.py +129 -0
  38. refactor_core/security/semgrep_runner.py +360 -0
  39. refactor_core/security/suppression.py +88 -0
  40. refactor_core/store.py +354 -0
  41. refactor_core/testcmd.py +64 -0
  42. refactorai_core-3.0.0.dist-info/METADATA +27 -0
  43. refactorai_core-3.0.0.dist-info/RECORD +45 -0
  44. refactorai_core-3.0.0.dist-info/WHEEL +5 -0
  45. refactorai_core-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,27 @@
1
+ """Shared core for the refactor platform.
2
+
3
+ This package is the single source of truth for code indexing, pipeline data
4
+ contracts, quality-gate evaluation, constitution parsing, and central-store
5
+ path resolution. It is imported by both the CLI (`refactor`) and, going
6
+ forward, the server-side worker so local and server runs stay in parity
7
+ (see docs/15-local-cli-and-constitution-spec.md, ADR-204).
8
+ """
9
+
10
+ from refactor_core.models import (
11
+ Finding,
12
+ GateResult,
13
+ GeneratedTest,
14
+ PatchGroup,
15
+ RunArtifact,
16
+ )
17
+
18
+ __all__ = [
19
+ "Finding",
20
+ "GateResult",
21
+ "GeneratedTest",
22
+ "PatchGroup",
23
+ "RunArtifact",
24
+ "__version__",
25
+ ]
26
+
27
+ __version__ = "3.0.0"
refactor_core/apply.py ADDED
@@ -0,0 +1,252 @@
1
+ """Unified-diff parsing and atomic in-place application with backup/revert.
2
+
3
+ Patches are produced as unified diffs (the run artifact). Applying them is
4
+ atomic: all target files are computed first (raising on any mismatch), the
5
+ originals are snapshotted into the run's ``backup/`` directory, then the new
6
+ contents are written. ``revert`` restores from that snapshot.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import difflib
12
+ import json
13
+ import re
14
+ import shutil
15
+ from dataclasses import dataclass, field
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+
20
+ def make_unified_diff(path: str, original_text: str, new_text: str) -> str:
21
+ """Build a unified diff for a single file (consistent with the applier)."""
22
+ a = original_text.split("\n")
23
+ b = new_text.split("\n")
24
+ diff = difflib.unified_diff(a, b, fromfile=f"a/{path}", tofile=f"b/{path}", lineterm="")
25
+ return "\n".join(diff)
26
+
27
+ _HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
28
+ APPLIED_MARKER = "applied.json"
29
+ BACKUP_DIR = "backup"
30
+
31
+
32
+ class ApplyError(RuntimeError):
33
+ """Raised when a diff cannot be cleanly applied."""
34
+
35
+
36
+ @dataclass
37
+ class Hunk:
38
+ old_start: int
39
+ lines: list[tuple[str, str]] = field(default_factory=list)
40
+
41
+
42
+ @dataclass
43
+ class FilePatch:
44
+ path: str
45
+ hunks: list[Hunk] = field(default_factory=list)
46
+
47
+
48
+ def _strip_ab_prefix(path: str) -> str:
49
+ path = path.strip()
50
+ if path.startswith(("a/", "b/")):
51
+ return path[2:]
52
+ return path
53
+
54
+
55
+ def parse_unified_diff(diff_text: str) -> list[FilePatch]:
56
+ """Parse a (possibly multi-file) unified diff into ``FilePatch`` objects."""
57
+ patches: list[FilePatch] = []
58
+ current: FilePatch | None = None
59
+ lines = diff_text.split("\n")
60
+ i = 0
61
+ while i < len(lines):
62
+ line = lines[i]
63
+ if line.startswith("--- "):
64
+ i += 1
65
+ continue
66
+ if line.startswith("+++ "):
67
+ current = FilePatch(path=_strip_ab_prefix(line[4:]))
68
+ patches.append(current)
69
+ i += 1
70
+ continue
71
+ match = _HUNK_RE.match(line)
72
+ if match:
73
+ if current is None:
74
+ raise ApplyError("Hunk encountered before a file header")
75
+ hunk = Hunk(old_start=int(match.group(1)))
76
+ i += 1
77
+ while i < len(lines):
78
+ hl = lines[i]
79
+ if not hl or hl[0] not in " -+" or hl.startswith(("--- ", "+++ ")) or _HUNK_RE.match(hl):
80
+ break
81
+ hunk.lines.append((hl[0], hl[1:]))
82
+ i += 1
83
+ current.hunks.append(hunk)
84
+ continue
85
+ i += 1
86
+ return patches
87
+
88
+
89
+ def apply_file_patch(original_text: str, hunks: list[Hunk]) -> str:
90
+ """Apply hunks to ``original_text`` and return the new text."""
91
+ original = original_text.split("\n")
92
+ result: list[str] = []
93
+ idx = 0 # 0-based cursor into original
94
+
95
+ for hunk in hunks:
96
+ start = max(hunk.old_start - 1, 0)
97
+ if start < idx:
98
+ raise ApplyError("Overlapping or out-of-order hunks")
99
+ result.extend(original[idx:start])
100
+ idx = start
101
+ for tag, content in hunk.lines:
102
+ if tag == " ":
103
+ if idx >= len(original) or original[idx] != content:
104
+ raise ApplyError(f"Context mismatch at line {idx + 1}: expected {content!r}")
105
+ result.append(original[idx])
106
+ idx += 1
107
+ elif tag == "-":
108
+ if idx >= len(original) or original[idx] != content:
109
+ raise ApplyError(f"Removal mismatch at line {idx + 1}: expected {content!r}")
110
+ idx += 1
111
+ elif tag == "+":
112
+ result.append(content)
113
+ result.extend(original[idx:])
114
+ return "\n".join(result)
115
+
116
+
117
+ def validate_diff(project_root: Path | str, diff_text: str) -> tuple[bool, str]:
118
+ """Validate a single unified diff without mutating any files (R9, FR-13).
119
+
120
+ Returns ``(ok, reason)``. A diff is valid when it has at least one file
121
+ header, parses, and applies cleanly against current on-disk content.
122
+ """
123
+ if not diff_text or not diff_text.strip():
124
+ return False, "empty diff"
125
+ try:
126
+ patches = parse_unified_diff(diff_text)
127
+ except ApplyError as exc:
128
+ return False, f"malformed diff: {exc}"
129
+ if not patches:
130
+ return False, "no file headers (expected 'a/<path>' and 'b/<path>')"
131
+ if not any(fp.hunks for fp in patches):
132
+ return False, "no hunks to apply"
133
+ try:
134
+ compute_file_changes(project_root, [diff_text])
135
+ except ApplyError as exc:
136
+ return False, f"does not apply cleanly: {exc}"
137
+ return True, ""
138
+
139
+
140
+ def compute_file_changes(project_root: Path | str, diff_texts: list[str]) -> dict[str, str]:
141
+ """Compute new file contents for all diffs without writing anything.
142
+
143
+ Raises ``ApplyError`` if any hunk fails to apply, so callers can abort
144
+ before touching the working tree.
145
+ """
146
+ project_root = Path(project_root)
147
+ changes: dict[str, str] = {}
148
+ for diff_text in diff_texts:
149
+ for file_patch in parse_unified_diff(diff_text):
150
+ target = project_root / file_patch.path
151
+ if file_patch.path in changes:
152
+ base = changes[file_patch.path]
153
+ elif target.exists():
154
+ base = target.read_text(encoding="utf-8")
155
+ else:
156
+ base = ""
157
+ changes[file_patch.path] = apply_file_patch(base, file_patch.hunks)
158
+ return changes
159
+
160
+
161
+ def apply_changes(project_root: Path | str, run_dir: Path, changes: dict[str, str]) -> list[str]:
162
+ """Snapshot originals into the run backup dir, then write new contents.
163
+
164
+ Returns the list of changed relative paths. On a write failure, restores
165
+ from the just-written backups and re-raises.
166
+ """
167
+ project_root = Path(project_root)
168
+ backup_root = run_dir / BACKUP_DIR
169
+ backup_root.mkdir(parents=True, exist_ok=True)
170
+
171
+ existing: list[str] = []
172
+ new_files: list[str] = []
173
+ for rel in changes:
174
+ src = project_root / rel
175
+ if src.exists():
176
+ dst = backup_root / rel
177
+ dst.parent.mkdir(parents=True, exist_ok=True)
178
+ shutil.copy2(src, dst)
179
+ existing.append(rel)
180
+ else:
181
+ new_files.append(rel)
182
+
183
+ written: list[str] = []
184
+ try:
185
+ for rel, text in changes.items():
186
+ dest = project_root / rel
187
+ dest.parent.mkdir(parents=True, exist_ok=True)
188
+ dest.write_text(text, encoding="utf-8")
189
+ written.append(rel)
190
+ except OSError as exc:
191
+ _restore(project_root, backup_root, existing, new_files_written=written, new_files=new_files)
192
+ raise ApplyError(f"Write failed, rolled back: {exc}") from exc
193
+
194
+ _write_marker(run_dir, status="applied", existing=existing, new_files=new_files)
195
+ return written
196
+
197
+
198
+ def revert_from_backup(project_root: Path | str, run_dir: Path) -> list[str]:
199
+ """Restore files from the run's backup snapshot."""
200
+ project_root = Path(project_root)
201
+ marker = read_marker(run_dir)
202
+ if not marker or marker.get("status") != "applied":
203
+ raise ApplyError("Latest run has no applied changes to revert")
204
+
205
+ existing = marker.get("files", [])
206
+ new_files = marker.get("new_files", [])
207
+ restored = _restore(project_root, run_dir / BACKUP_DIR, existing, new_files_written=new_files, new_files=new_files)
208
+ _write_marker(run_dir, status="reverted", existing=existing, new_files=new_files)
209
+ return restored
210
+
211
+
212
+ def _restore(project_root: Path, backup_root: Path, existing: list[str], *, new_files_written: list[str], new_files: list[str]) -> list[str]:
213
+ restored: list[str] = []
214
+ for rel in existing:
215
+ backup = backup_root / rel
216
+ if backup.exists():
217
+ dest = project_root / rel
218
+ dest.parent.mkdir(parents=True, exist_ok=True)
219
+ shutil.copy2(backup, dest)
220
+ restored.append(rel)
221
+ for rel in new_files_written:
222
+ if rel in new_files:
223
+ target = project_root / rel
224
+ if target.exists():
225
+ target.unlink()
226
+ restored.append(rel)
227
+ return restored
228
+
229
+
230
+ def _write_marker(run_dir: Path, *, status: str, existing: list[str], new_files: list[str]) -> None:
231
+ (run_dir / APPLIED_MARKER).write_text(
232
+ json.dumps(
233
+ {
234
+ "status": status,
235
+ "files": existing,
236
+ "new_files": new_files,
237
+ "at": datetime.now(timezone.utc).isoformat(),
238
+ },
239
+ indent=2,
240
+ ),
241
+ encoding="utf-8",
242
+ )
243
+
244
+
245
+ def read_marker(run_dir: Path) -> dict | None:
246
+ path = run_dir / APPLIED_MARKER
247
+ if not path.is_file():
248
+ return None
249
+ try:
250
+ return json.loads(path.read_text(encoding="utf-8"))
251
+ except (json.JSONDecodeError, OSError):
252
+ return None
@@ -0,0 +1,153 @@
1
+ """Model-aware context budgeting and deterministic batch packing (R9.1, ADR-211).
2
+
3
+ Turns a flat list of index documents into one or more batches that each fit an
4
+ effective input-token budget derived from the resolved model context window and
5
+ a reserved output allowance. Both execution modes consume this so no single
6
+ provider call exceeds the model's limits.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from dataclasses import dataclass
13
+
14
+ from refactor_core.capabilities import CapabilitySnapshot
15
+
16
+ # Conservative chars-per-token estimate. Real tokenizers vary; we err on the
17
+ # side of *fewer* chars per token (i.e. higher token estimate) for safety.
18
+ CHARS_PER_TOKEN = 3.5
19
+ DEFAULT_BUDGET_RATIO = 0.55
20
+ # Per-request prompt scaffolding (instructions + constitution) reserve.
21
+ PROMPT_OVERHEAD_TOKENS = 1_500
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Budget:
26
+ context_window: int
27
+ reserved_output_tokens: int
28
+ input_budget_tokens: int
29
+ ratio: float
30
+
31
+
32
+ @dataclass
33
+ class BatchPlan:
34
+ budget: Budget
35
+ batches: list[list[dict]]
36
+ estimated_tokens: list[int]
37
+ total_documents: int
38
+ skipped_oversized: int
39
+
40
+ @property
41
+ def batch_count(self) -> int:
42
+ return len(self.batches)
43
+
44
+ @property
45
+ def total_estimated_tokens(self) -> int:
46
+ return sum(self.estimated_tokens)
47
+
48
+
49
+ def estimate_tokens(text: str) -> int:
50
+ """Conservative token estimate for a chunk of text."""
51
+ if not text:
52
+ return 0
53
+ return max(1, math.ceil(len(text) / CHARS_PER_TOKEN))
54
+
55
+
56
+ def estimate_document_tokens(doc: dict) -> int:
57
+ """Estimate tokens for a single index document, including light JSON framing."""
58
+ chunk = doc.get("chunk_text", "") or ""
59
+ path = doc.get("path", "") or ""
60
+ # ~20 tokens of structural framing per document (keys, braces, language).
61
+ return estimate_tokens(chunk) + estimate_tokens(path) + 20
62
+
63
+
64
+ def compute_budget(
65
+ capabilities: CapabilitySnapshot,
66
+ *,
67
+ ratio: float = DEFAULT_BUDGET_RATIO,
68
+ output_ceiling: int | None = None,
69
+ ) -> Budget:
70
+ """Derive the effective input-token budget for a single provider call."""
71
+ ratio = _clamp_ratio(ratio)
72
+ context_window = max(1, int(capabilities.context_window))
73
+ reserved = int(capabilities.max_output_tokens)
74
+ if output_ceiling is not None:
75
+ reserved = min(reserved, max(1, int(output_ceiling)))
76
+ reserved = min(reserved, context_window - 1)
77
+
78
+ usable = (context_window - reserved) * ratio
79
+ input_budget = int(usable) - PROMPT_OVERHEAD_TOKENS
80
+ input_budget = max(input_budget, 1)
81
+ return Budget(
82
+ context_window=context_window,
83
+ reserved_output_tokens=reserved,
84
+ input_budget_tokens=input_budget,
85
+ ratio=ratio,
86
+ )
87
+
88
+
89
+ def plan_batches(
90
+ documents: list[dict],
91
+ capabilities: CapabilitySnapshot,
92
+ *,
93
+ ratio: float = DEFAULT_BUDGET_RATIO,
94
+ output_ceiling: int | None = None,
95
+ ) -> BatchPlan:
96
+ """Pack documents into context-safe batches (deterministic, input order).
97
+
98
+ A document larger than the whole input budget is placed in its own batch
99
+ (the provider/pipeline may further truncate); it is counted in
100
+ ``skipped_oversized`` for observability but is never silently dropped.
101
+ """
102
+ budget = compute_budget(capabilities, ratio=ratio, output_ceiling=output_ceiling)
103
+
104
+ batches: list[list[dict]] = []
105
+ estimated: list[int] = []
106
+ current: list[dict] = []
107
+ current_tokens = 0
108
+ skipped_oversized = 0
109
+
110
+ for doc in documents:
111
+ doc_tokens = estimate_document_tokens(doc)
112
+
113
+ if doc_tokens > budget.input_budget_tokens:
114
+ skipped_oversized += 1
115
+ if current:
116
+ batches.append(current)
117
+ estimated.append(current_tokens)
118
+ current = []
119
+ current_tokens = 0
120
+ batches.append([doc])
121
+ estimated.append(doc_tokens)
122
+ continue
123
+
124
+ if current and current_tokens + doc_tokens > budget.input_budget_tokens:
125
+ batches.append(current)
126
+ estimated.append(current_tokens)
127
+ current = []
128
+ current_tokens = 0
129
+
130
+ current.append(doc)
131
+ current_tokens += doc_tokens
132
+
133
+ if current:
134
+ batches.append(current)
135
+ estimated.append(current_tokens)
136
+
137
+ return BatchPlan(
138
+ budget=budget,
139
+ batches=batches,
140
+ estimated_tokens=estimated,
141
+ total_documents=len(documents),
142
+ skipped_oversized=skipped_oversized,
143
+ )
144
+
145
+
146
+ def _clamp_ratio(ratio: float) -> float:
147
+ try:
148
+ ratio = float(ratio)
149
+ except (TypeError, ValueError):
150
+ return DEFAULT_BUDGET_RATIO
151
+ if ratio <= 0 or ratio >= 1:
152
+ return DEFAULT_BUDGET_RATIO
153
+ return ratio
@@ -0,0 +1,56 @@
1
+ """Resolved provider/model capability snapshot (R9.1).
2
+
3
+ Combines a provider instance's declared metadata with the built-in model catalog
4
+ and ``model_context_overrides`` from ``refactor.config`` into a single snapshot
5
+ used by budgeting, mode resolution, and ``refactor doctor``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass
11
+
12
+ from refactor_core.model_catalog import ModelContext, resolve_model_context
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class CapabilitySnapshot:
17
+ provider: str
18
+ model_id: str
19
+ supports_tools: bool
20
+ supports_refactor: bool
21
+ context_window: int
22
+ max_output_tokens: int
23
+ source: str
24
+
25
+ def to_dict(self) -> dict:
26
+ return asdict(self)
27
+
28
+
29
+ def resolve_capabilities(provider, settings: dict | None = None) -> CapabilitySnapshot:
30
+ """Resolve effective capabilities for a provider instance.
31
+
32
+ Precedence for context/tool values: a provider that *explicitly* declares a
33
+ non-default ``supports_tools`` keeps it; otherwise the catalog/override value
34
+ wins. Context-window and output-token ceilings always come from the catalog/
35
+ override resolution (the authoritative per-model source).
36
+ """
37
+ settings = settings or {}
38
+ provider_name = getattr(provider, "provider_name", "unknown")
39
+ model_id = getattr(provider, "model_id", "unknown")
40
+ overrides = settings.get("model_context_overrides")
41
+
42
+ ctx: ModelContext = resolve_model_context(provider_name, model_id, overrides=overrides)
43
+
44
+ # Honor an explicit provider-level opt-in to tools if the catalog is unsure.
45
+ declared_tools = bool(getattr(provider, "supports_tools", False))
46
+ supports_tools = ctx.supports_tools or declared_tools
47
+
48
+ return CapabilitySnapshot(
49
+ provider=provider_name,
50
+ model_id=model_id,
51
+ supports_tools=supports_tools,
52
+ supports_refactor=bool(getattr(provider, "supports_refactor", True)),
53
+ context_window=ctx.context_window,
54
+ max_output_tokens=ctx.max_output_tokens,
55
+ source=ctx.source,
56
+ )
@@ -0,0 +1,34 @@
1
+ """Compliance pack loading/projection namespace (R18)."""
2
+
3
+ from refactor_core.compliance.evidence import (
4
+ ComplianceGateDecision,
5
+ build_compliance_evidence_bundle,
6
+ evaluate_compliance_gate,
7
+ )
8
+ from refactor_core.compliance.loader import (
9
+ CompliancePackError,
10
+ load_compliance_pack_for_framework,
11
+ resolve_compliance_packs,
12
+ )
13
+ from refactor_core.compliance.models import (
14
+ CompliancePack,
15
+ CompliancePackResolution,
16
+ ComplianceProjectedRule,
17
+ ComplianceRuleDef,
18
+ )
19
+ from refactor_core.compliance.projection import project_compliance_for_finding
20
+
21
+ __all__ = [
22
+ "CompliancePack",
23
+ "CompliancePackError",
24
+ "ComplianceGateDecision",
25
+ "CompliancePackResolution",
26
+ "ComplianceProjectedRule",
27
+ "ComplianceRuleDef",
28
+ "build_compliance_evidence_bundle",
29
+ "evaluate_compliance_gate",
30
+ "load_compliance_pack_for_framework",
31
+ "project_compliance_for_finding",
32
+ "resolve_compliance_packs",
33
+ ]
34
+
@@ -0,0 +1,109 @@
1
+ """Project-context detectors for compliance projection (R18 Phase 3)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ _SENSITIVE_SEGMENTS = {
9
+ "auth",
10
+ "iam",
11
+ "billing",
12
+ "payment",
13
+ "payments",
14
+ "audit",
15
+ "security",
16
+ "crypto",
17
+ "pii",
18
+ "privacy",
19
+ "ephi",
20
+ }
21
+
22
+ _CODE_EXTS = {
23
+ ".py",
24
+ ".js",
25
+ ".jsx",
26
+ ".ts",
27
+ ".tsx",
28
+ ".java",
29
+ ".go",
30
+ ".cs",
31
+ ".php",
32
+ ".rb",
33
+ ".rs",
34
+ ".cpp",
35
+ ".cc",
36
+ ".c",
37
+ ".h",
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ComplianceProjectContext:
43
+ """Derived context used by rule projection."""
44
+
45
+ project_root: Path
46
+ languages: tuple[str, ...]
47
+ has_package_json: bool
48
+ has_pyproject: bool
49
+ has_go_mod: bool
50
+
51
+
52
+ def detect_project_context(project_root: Path | str) -> ComplianceProjectContext:
53
+ root = Path(project_root)
54
+ langs: set[str] = set()
55
+ if (root / "package.json").is_file():
56
+ langs.update({"js", "ts"})
57
+ if (root / "pyproject.toml").is_file() or (root / "requirements.txt").is_file():
58
+ langs.add("py")
59
+ if (root / "go.mod").is_file():
60
+ langs.add("go")
61
+ # Cheap fallback: infer from extension seen in top-level/src.
62
+ for candidate in (root, root / "src"):
63
+ if not candidate.is_dir():
64
+ continue
65
+ for item in candidate.rglob("*"):
66
+ if not item.is_file():
67
+ continue
68
+ if item.suffix.lower() not in _CODE_EXTS:
69
+ continue
70
+ suffix = item.suffix.lower()
71
+ if suffix in {".js", ".jsx", ".ts", ".tsx"}:
72
+ langs.update({"js", "ts"})
73
+ elif suffix == ".py":
74
+ langs.add("py")
75
+ elif suffix == ".go":
76
+ langs.add("go")
77
+ elif suffix in {".java"}:
78
+ langs.add("java")
79
+ return ComplianceProjectContext(
80
+ project_root=root,
81
+ languages=tuple(sorted(langs)),
82
+ has_package_json=(root / "package.json").is_file(),
83
+ has_pyproject=(root / "pyproject.toml").is_file(),
84
+ has_go_mod=(root / "go.mod").is_file(),
85
+ )
86
+
87
+
88
+ def normalize_rel_path(value: str) -> str:
89
+ text = str(value or "").strip().replace("\\", "/").lstrip("./")
90
+ while "//" in text:
91
+ text = text.replace("//", "/")
92
+ return text.strip().lower()
93
+
94
+
95
+ def path_segments(path_value: str) -> tuple[str, ...]:
96
+ norm = normalize_rel_path(path_value)
97
+ if not norm:
98
+ return ()
99
+ return tuple(chunk for chunk in norm.split("/") if chunk)
100
+
101
+
102
+ def touches_sensitive_path(path_value: str) -> bool:
103
+ return any(seg in _SENSITIVE_SEGMENTS for seg in path_segments(path_value))
104
+
105
+
106
+ def looks_like_serializer(path_value: str) -> bool:
107
+ norm = normalize_rel_path(path_value)
108
+ return "serial" in norm or norm.endswith("_dto.py") or "schema" in norm
109
+
@@ -0,0 +1,45 @@
1
+ """Compliance gate/evidence helpers (R18 Phase 7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ComplianceGateDecision:
10
+ status: str # passed | blocked
11
+ reasons: tuple[str, ...] = ()
12
+
13
+
14
+ def evaluate_compliance_gate(compliance_summary: dict | None, *, base_gate_status: str) -> ComplianceGateDecision:
15
+ """Derive compliance gate outcome from summary payload and base gate status."""
16
+ summary = compliance_summary if isinstance(compliance_summary, dict) else {}
17
+ try:
18
+ blocked_count = int(summary.get("blocked_count", 0) or 0)
19
+ except (TypeError, ValueError):
20
+ blocked_count = 0
21
+ level = str(summary.get("level", "standard") or "standard").strip().lower()
22
+ if base_gate_status == "blocked":
23
+ return ComplianceGateDecision(status="blocked", reasons=("quality_gate_blocked",))
24
+ if blocked_count > 0 and level in {"standard", "strict"}:
25
+ return ComplianceGateDecision(status="blocked", reasons=("compliance_controls_blocked",))
26
+ return ComplianceGateDecision(status="passed", reasons=())
27
+
28
+
29
+ def build_compliance_evidence_bundle(report: dict) -> dict:
30
+ """Create an auditor-friendly compliance evidence payload from run report."""
31
+ payload = dict(report or {})
32
+ return {
33
+ "run_id": payload.get("run_id", ""),
34
+ "target": payload.get("target", ""),
35
+ "created_at": payload.get("created_at", ""),
36
+ "model_id": payload.get("model_id", ""),
37
+ "gate": payload.get("gate", {}),
38
+ "compliance_summary": payload.get("compliance_summary", {}),
39
+ "rule_trace": {
40
+ "rule_source": payload.get("rule_source", ""),
41
+ "rule_pattern": payload.get("rule_pattern", ""),
42
+ "rule_hash": payload.get("rule_hash", ""),
43
+ },
44
+ }
45
+