repopact 1.4.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 (32) hide show
  1. adopt_repo.py +376 -0
  2. check_frozen_surface.py +125 -0
  3. doctor.py +242 -0
  4. frontmatter.py +48 -0
  5. generate_dashboard.py +109 -0
  6. generate_spec.py +92 -0
  7. init_repo.py +145 -0
  8. new.py +87 -0
  9. plan_import.py +320 -0
  10. repo_model.py +67 -0
  11. repopact-1.4.0.data/data/share/repopact/schemas/audit-finding.schema.json +15 -0
  12. repopact-1.4.0.data/data/share/repopact/schemas/evidence-run.schema.json +16 -0
  13. repopact-1.4.0.data/data/share/repopact/schemas/frozen-surface.schema.json +21 -0
  14. repopact-1.4.0.data/data/share/repopact/schemas/invariants.schema.json +27 -0
  15. repopact-1.4.0.data/data/share/repopact/schemas/record-frontmatter.schema.json +29 -0
  16. repopact-1.4.0.data/data/share/repopact/schemas/work-item.schema.json +31 -0
  17. repopact-1.4.0.data/data/share/repopact/templates/README.md +14 -0
  18. repopact-1.4.0.data/data/share/repopact/templates/decision.md +25 -0
  19. repopact-1.4.0.data/data/share/repopact/templates/evidence-run.json +12 -0
  20. repopact-1.4.0.data/data/share/repopact/templates/policy.md +20 -0
  21. repopact-1.4.0.data/data/share/repopact/templates/work-item.README.md +24 -0
  22. repopact-1.4.0.data/data/share/repopact/templates/work-item.json +14 -0
  23. repopact-1.4.0.dist-info/METADATA +125 -0
  24. repopact-1.4.0.dist-info/RECORD +32 -0
  25. repopact-1.4.0.dist-info/WHEEL +5 -0
  26. repopact-1.4.0.dist-info/entry_points.txt +2 -0
  27. repopact-1.4.0.dist-info/licenses/LICENSE +201 -0
  28. repopact-1.4.0.dist-info/top_level.txt +14 -0
  29. repopact_cli.py +194 -0
  30. takeover.py +143 -0
  31. track_import.py +204 -0
  32. validate_repo.py +420 -0
adopt_repo.py ADDED
@@ -0,0 +1,376 @@
1
+ """Adopt RepoPact into an EXISTING repository (work item 008).
2
+
3
+ Where ``init_repo`` seeds a greenfield repo, ``adopt_repo`` reads an existing
4
+ project's governance signals and generates RepoPact records *around* them, without
5
+ overwriting a single existing file:
6
+
7
+ * ``CODEOWNERS`` -> scopes and roles in ``governance/owners.json``
8
+ * ``.github/workflows/*`` -> binding-gate policies + a CI invariant + frozen surface
9
+ * nested ``AGENTS.md`` -> registered contracts in ``audits/registry.json``
10
+ (with a stub ``_audit`` triplet created only where an ``_audit`` dir already exists)
11
+ * git history -> the first evidence run and a completed adoption work item
12
+
13
+ Everything it writes is created only if absent. Run with ``--dry-run`` to see the
14
+ plan without touching the tree. The result is validated before it returns.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import re
21
+ import subprocess
22
+ import sys
23
+ from datetime import date, datetime, timedelta
24
+ from pathlib import Path
25
+
26
+ import init_repo # reuse _seed_dir, _json/_write semantics, LIFECYCLE
27
+
28
+
29
+ # --- non-destructive primitives --------------------------------------------
30
+
31
+ class Report:
32
+ def __init__(self, dry_run: bool) -> None:
33
+ self.dry_run = dry_run
34
+ self.created: list[str] = []
35
+ self.skipped: list[str] = []
36
+ self.gitignored: list[str] = [] # created records an existing .gitignore would swallow (F-008)
37
+
38
+ def write(self, path: Path, text: str, root: Path) -> None:
39
+ rel = str(path.relative_to(root)).replace("\\", "/")
40
+ if path.exists():
41
+ self.skipped.append(rel)
42
+ return
43
+ self.created.append(rel)
44
+ if not self.dry_run:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ path.write_text(text, encoding="utf-8")
47
+
48
+ def json(self, path: Path, data: object, root: Path) -> None:
49
+ import json
50
+ self.write(path, json.dumps(data, indent=2) + "\n", root)
51
+
52
+ def copy_seed(self, name: str, root: Path) -> None:
53
+ """Copy schemas/ or templates/ from the install, file by file, never clobbering."""
54
+ import shutil
55
+ src = init_repo._seed_dir(name)
56
+ for item in src.iterdir():
57
+ dest = root / name / item.name
58
+ rel = f"{name}/{item.name}"
59
+ if dest.exists():
60
+ self.skipped.append(rel)
61
+ continue
62
+ self.created.append(rel)
63
+ if not self.dry_run:
64
+ dest.parent.mkdir(parents=True, exist_ok=True)
65
+ shutil.copy2(item, dest)
66
+
67
+
68
+ # --- detection --------------------------------------------------------------
69
+
70
+ def detect_workflows(root: Path) -> list[Path]:
71
+ wf = root / ".github" / "workflows"
72
+ if not wf.is_dir():
73
+ return []
74
+ return sorted(p for p in wf.iterdir() if p.suffix in (".yml", ".yaml"))
75
+
76
+
77
+ def workflow_name(path: Path) -> str:
78
+ try:
79
+ for line in path.read_text(encoding="utf-8").splitlines():
80
+ m = re.match(r"\s*name:\s*(.+?)\s*$", line)
81
+ if m:
82
+ return m.group(1).strip().strip("'\"")
83
+ except OSError:
84
+ pass
85
+ return path.stem
86
+
87
+
88
+ def parse_codeowners(root: Path) -> dict[str, list[str]]:
89
+ """Return owner-handle -> list of RepoPact-style path globs."""
90
+ for candidate in (root / ".github" / "CODEOWNERS", root / "CODEOWNERS", root / "docs" / "CODEOWNERS"):
91
+ if candidate.is_file():
92
+ text = candidate.read_text(encoding="utf-8")
93
+ break
94
+ else:
95
+ return {}
96
+ owners: dict[str, list[str]] = {}
97
+ for raw in text.splitlines():
98
+ line = raw.split("#", 1)[0].strip()
99
+ if not line:
100
+ continue
101
+ parts = line.split()
102
+ if len(parts) < 2:
103
+ continue
104
+ pattern, handles = parts[0], parts[1:]
105
+ glob = pattern.lstrip("/")
106
+ if glob.endswith("/"):
107
+ glob += "**"
108
+ for handle in handles:
109
+ owners.setdefault(handle, [])
110
+ if glob and glob not in owners[handle]:
111
+ owners[handle].append(glob)
112
+ return owners
113
+
114
+
115
+ def find_nested_contracts(root: Path) -> list[Path]:
116
+ """Nested AGENTS.md contracts, using the validator's own discovery (which excludes
117
+ tooling caches and test fixtures) so adopt/doctor stay consistent with validation."""
118
+ import repo_model
119
+ return [c for c in repo_model.iter_contracts(root) if c.parent != root]
120
+
121
+
122
+ def git_stats(root: Path) -> dict[str, object]:
123
+ def run(args: list[str]) -> str | None:
124
+ try:
125
+ r = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=True)
126
+ return r.stdout.strip()
127
+ except (OSError, subprocess.CalledProcessError):
128
+ return None
129
+ commits = run(["rev-list", "--count", "HEAD"])
130
+ head = run(["rev-parse", "--short", "HEAD"])
131
+ tag = run(["describe", "--tags", "--abbrev=0"])
132
+ contributors = run(["shortlog", "-sne", "HEAD"])
133
+ n_contrib = len(contributors.splitlines()) if contributors else None
134
+ return {
135
+ "commits": int(commits) if commits and commits.isdigit() else None,
136
+ "head": head,
137
+ "latest_tag": tag,
138
+ "contributors": n_contrib,
139
+ }
140
+
141
+
142
+ def gitignored_records(root: Path, rels: list[str]) -> list[str]:
143
+ """Which of the generated records would an existing .gitignore swallow (F-008)?
144
+
145
+ A record that is ignored validates on the author's disk but is missing on a fresh
146
+ clone or in CI, silently breaking the adopted repository. Best-effort: returns []
147
+ outside a git checkout or if git is unavailable.
148
+ """
149
+ if not rels:
150
+ return []
151
+ try:
152
+ result = subprocess.run(["git", "check-ignore", "--stdin"], cwd=root,
153
+ input="\n".join(rels), capture_output=True, text=True)
154
+ except OSError:
155
+ return []
156
+ if result.returncode not in (0, 1): # 0 = some ignored, 1 = none ignored
157
+ return []
158
+ return [line.strip().replace("\\", "/") for line in result.stdout.splitlines() if line.strip()]
159
+
160
+
161
+ def _slug(text: str) -> str:
162
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "item"
163
+
164
+
165
+ def _detect_version(root: Path) -> str:
166
+ existing = root / "VERSION"
167
+ if existing.is_file():
168
+ v = existing.read_text(encoding="utf-8").strip()
169
+ if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", v):
170
+ return v
171
+ pyproject = root / "pyproject.toml"
172
+ if pyproject.is_file():
173
+ m = re.search(r'(?m)^\s*version\s*=\s*["\']([0-9]+\.[0-9]+\.[0-9]+)["\']', pyproject.read_text(encoding="utf-8"))
174
+ if m:
175
+ return m.group(1)
176
+ return "0.1.0"
177
+
178
+
179
+ # --- adoption ---------------------------------------------------------------
180
+
181
+ def adopt(target: Path, today: date | None = None, dry_run: bool = False) -> Report:
182
+ today = today or date.today()
183
+ next_review = (today + timedelta(days=90)).isoformat()
184
+ rep = Report(dry_run)
185
+ target.mkdir(parents=True, exist_ok=True)
186
+
187
+ workflows = detect_workflows(target)
188
+ codeowners = parse_codeowners(target)
189
+ contracts = find_nested_contracts(target)
190
+ stats = git_stats(target)
191
+
192
+ rep.copy_seed("schemas", target)
193
+ rep.copy_seed("templates", target)
194
+
195
+ rep.write(target / "VERSION", _detect_version(target) + "\n", target)
196
+ rep.write(target / "AGENTS.md",
197
+ "# Agent Contract\n\nThe repository is the durable coordination surface. The invariants in\n"
198
+ "`governance/invariants.json` are binding; weakening one requires operator approval.\n"
199
+ "Read every `AGENTS.md` from root to the file you touch.\n", target)
200
+
201
+ # governance/owners.json from CODEOWNERS, always including a governance scope.
202
+ scopes = [{"id": "governance", "paths": ["AGENTS.md", "governance/**", "schemas/**"], "owner": "governance-owner"}]
203
+ roles = [{"id": "governance-owner", "description": "Maintains the pact and schemas.", "scopes": ["governance"]}]
204
+ for handle, paths in codeowners.items():
205
+ scope_id = _slug(handle.lstrip("@"))
206
+ if scope_id == "governance" or any(s["id"] == scope_id for s in scopes):
207
+ scope_id = f"{scope_id}-owned"
208
+ scopes.append({"id": scope_id, "paths": paths or ["**"], "owner": handle})
209
+ roles.append({"id": scope_id, "description": f"Owns paths assigned to {handle} in CODEOWNERS.", "scopes": [scope_id]})
210
+ rep.json(target / "governance" / "owners.json",
211
+ {"version": 2, "scopes": scopes, "roles": roles,
212
+ "concurrency": {"enforce_disjoint_active_scopes": False}}, target)
213
+
214
+ rep.write(target / "governance" / "charter.md",
215
+ "# Charter\n\n## Principles (human judgment)\n\n1. Systems before sessions.\n"
216
+ "2. Completion requires proof.\n\n## Invariants (binding)\n\nSee `invariants.json`.\n", target)
217
+ rep.write(target / "governance" / "workflow.md",
218
+ "# Operating Workflow\n\nCapture intent in a work item, resolve authority, implement in scope,\n"
219
+ "produce evidence, reconcile, then transition state.\n", target)
220
+
221
+ invariants = [{
222
+ "id": "INV-1",
223
+ "statement": "No critical state exists only in conversation; it lives in versioned files.",
224
+ "rationale": "State must be recoverable without a prior conversation.",
225
+ "escalation": "If a task would leave load-bearing state only in chat, record it as a file first.",
226
+ "enforced_by": None,
227
+ }]
228
+ if workflows:
229
+ invariants.append({
230
+ "id": "INV-2",
231
+ "statement": "Declared CI workflows are binding gates; removing or weakening one requires operator approval.",
232
+ "rationale": "CI is the enforcement substrate that the project's correctness claims rest on.",
233
+ "escalation": "Flag any change that deletes or disables a workflow and confirm with the operator.",
234
+ "enforced_by": ".github/workflows",
235
+ })
236
+ rep.json(target / "governance" / "invariants.json",
237
+ {"$schema": "../schemas/invariants.schema.json", "version": 1, "invariants": invariants}, target)
238
+
239
+ protected = [{"glob": "governance/invariants.json",
240
+ "reason": "Invariants are the pact; weakening requires operator approval.", "symbols": []}]
241
+ if workflows:
242
+ protected.append({"glob": ".github/workflows/**",
243
+ "reason": "CI is the enforcement substrate; changes need human review.", "symbols": []})
244
+ if codeowners:
245
+ protected.append({"glob": "CODEOWNERS",
246
+ "reason": "Ownership mapping; changing who can approve what needs review.", "symbols": []})
247
+ rep.json(target / "governance" / "frozen-surface.json",
248
+ {"$schema": "../schemas/frozen-surface.schema.json", "version": 1, "protected": protected}, target)
249
+
250
+ # One policy per detected workflow: the existing gate, recorded as an operating rule.
251
+ for i, wf in enumerate(workflows, start=1):
252
+ name = workflow_name(wf)
253
+ rel = str(wf.relative_to(target)).replace("\\", "/")
254
+ pid = f"{i:03d}"
255
+ rep.write(target / "governance" / "policies" / f"{pid}-ci-{_slug(name)}.md",
256
+ f"---\nid: {pid}\ntitle: 'CI gate: {name}'\nstatus: active\napplies_to: '{rel}'\n---\n\n"
257
+ f"# {pid}: CI gate — {name}\n\nThe workflow [`{rel}`]({rel}) is a binding gate adopted into the\n"
258
+ f"pact. It must pass before merge; disabling or weakening it requires operator approval (INV-2).\n", target)
259
+
260
+ # audits/registry.json: root contract + every nested AGENTS.md, with _audit triplets stubbed.
261
+ registry_scopes = [{"path": ".", "owner": "governance-owner", "contract": "AGENTS.md",
262
+ "last_reviewed": today.isoformat(), "next_review": next_review,
263
+ "alignment": "current", "notes": "Adopted repository root contract."}]
264
+ for contract in contracts:
265
+ rel_dir = str(contract.parent.relative_to(target)).replace("\\", "/")
266
+ rel_contract = str(contract.relative_to(target)).replace("\\", "/")
267
+ registry_scopes.append({"path": rel_dir, "owner": "governance-owner", "contract": rel_contract,
268
+ "last_reviewed": today.isoformat(), "next_review": next_review,
269
+ "alignment": "current", "notes": "Existing nested contract registered on adoption."})
270
+ audit_dir = contract.parent / "_audit"
271
+ if audit_dir.is_dir():
272
+ for fname, body in (("README.md", f"# _audit — {rel_dir}\n\nAudit companion for the `{rel_dir}` contract.\n"),
273
+ ("inventory.md", "# Inventory\n\nStub created on RepoPact adoption; fill with the scope's inventory.\n"),
274
+ ("alignment-report.md", "# Alignment report\n\nStub created on RepoPact adoption; fill with the alignment review.\n")):
275
+ rep.write(audit_dir / fname, body, target)
276
+ rep.json(target / "audits" / "registry.json", {"version": 1, "scopes": registry_scopes}, target)
277
+
278
+ # Lifecycle + empty dirs.
279
+ if not dry_run:
280
+ for status in init_repo.LIFECYCLE:
281
+ (target / "work" / status).mkdir(parents=True, exist_ok=True)
282
+ for empty in ("evidence/runs", "decisions", "governance/policies", "audits/findings", "audits/reports"):
283
+ (target / empty).mkdir(parents=True, exist_ok=True)
284
+
285
+ # The adoption itself: a completed work item proven by an evidence run over the scan.
286
+ ts = datetime.now()
287
+ ev_id = f"{ts.strftime('%Y%m%d-%H%M%S')}-adopt"
288
+ summary = (f"Adopted RepoPact into an existing repository: {stats.get('commits')} commits, "
289
+ f"{len(workflows)} CI workflow(s), {len(codeowners)} CODEOWNERS handle(s), "
290
+ f"{len(contracts)} nested contract(s).")
291
+ rep.json(target / "evidence" / "runs" / f"{ev_id}.json", {
292
+ "$schema": "../../schemas/evidence-run.schema.json",
293
+ "id": ev_id, "timestamp": ts.replace(microsecond=0).astimezone().isoformat(),
294
+ "work_item": "000", "result": "passed",
295
+ "commands": [
296
+ {"command": "git rev-list --count HEAD", "exit_code": 0, "summary": f"{stats.get('commits')} commits of history adopted."},
297
+ {"command": "repopact adopt", "exit_code": 0, "summary": summary},
298
+ ],
299
+ "artifacts": [str(w.relative_to(target)).replace("\\", "/") for w in workflows],
300
+ "environment": {"platform": sys.platform},
301
+ }, target)
302
+
303
+ wi_dir = target / "work" / "completed" / "000-adopt-repopact"
304
+ rep.json(wi_dir / "work-item.json", {
305
+ "$schema": "../../../schemas/work-item.schema.json",
306
+ "id": "000", "title": "Adopt RepoPact into the existing repository",
307
+ "status": "completed", "owner_scope": "governance", "affected_scopes": ["governance"],
308
+ "depends_on": [],
309
+ "acceptance_criteria": [
310
+ {"id": "AC-1", "text": "Existing CODEOWNERS, CI workflows, and nested contracts are represented as RepoPact records.",
311
+ "state": "satisfied", "evidence": [ev_id]},
312
+ {"id": "AC-2", "text": "The repository validates as a conformant RepoPact.",
313
+ "state": "satisfied", "evidence": [ev_id]},
314
+ ],
315
+ "created": today.isoformat(), "updated": today.isoformat(),
316
+ }, target)
317
+ rep.write(wi_dir / "README.md",
318
+ "# 000 — Adopt RepoPact into the existing repository\n\n"
319
+ "> **Status**: ✅ Complete\n\n## Intent\n\n"
320
+ "Bring an existing project under RepoPact governance by mapping its already-present\n"
321
+ "ownership (CODEOWNERS), enforcement (CI workflows), and contracts (nested `AGENTS.md`)\n"
322
+ "into RepoPact records, then proving the result validates.\n\n## Closeout\n\n"
323
+ f"Satisfied by evidence run `{ev_id}`. {summary}\n", target)
324
+
325
+ rep.write(target / "decisions" / "0001-adopt-repopact.md",
326
+ "---\nid: 0001\ntitle: Adopt RepoPact\nstatus: accepted\n"
327
+ f"date: {today.isoformat()}\nsupersedes: []\n---\n\n# 0001: Adopt RepoPact\n\n## Context\n\n"
328
+ "The project already had ad-hoc governance (CODEOWNERS, CI gates, AGENTS.md). RepoPact\n"
329
+ "makes those bindings explicit and machine-checkable.\n\n## Decision\n\n"
330
+ "Adopt RepoPact; existing workflows become binding gates (INV-2) and ownership becomes\n"
331
+ "scopes/roles. Existing files were preserved; RepoPact records were added around them.\n", target)
332
+
333
+ rep.gitignored = gitignored_records(target, rep.created)
334
+ return rep
335
+
336
+
337
+ def _print_report(rep: Report) -> None:
338
+ verb = "Would create" if rep.dry_run else "Created"
339
+ print(f"{verb} {len(rep.created)} record(s); skipped {len(rep.skipped)} existing file(s).")
340
+ for rel in rep.created:
341
+ print(f" + {rel}")
342
+ if rep.gitignored:
343
+ print("\nWARNING: the repository's .gitignore would un-track these RepoPact records (F-008):")
344
+ for rel in rep.gitignored:
345
+ print(f" ! {rel}")
346
+ print("They validate locally but would be MISSING on a fresh clone or in CI. Add negations, e.g.:")
347
+ for rel in sorted({f"!/{r.rsplit('/', 1)[0]}/" for r in rep.gitignored if "/" in r}):
348
+ print(f" {rel}")
349
+ print(" (then re-include the files, e.g. `!/evidence/runs/*.json`).")
350
+
351
+
352
+ def main() -> int:
353
+ parser = argparse.ArgumentParser(description="Adopt RepoPact into an existing repository")
354
+ parser.add_argument("--target", type=Path, required=True)
355
+ parser.add_argument("--dry-run", action="store_true", help="Report the plan without writing files")
356
+ args = parser.parse_args()
357
+ target = args.target.resolve()
358
+ rep = adopt(target, dry_run=args.dry_run)
359
+ _print_report(rep)
360
+ if args.dry_run:
361
+ print("\nDry run: nothing written. Re-run without --dry-run to apply.")
362
+ return 0
363
+
364
+ import validate_repo
365
+ problems = validate_repo.validate(target)
366
+ if problems:
367
+ for p in problems:
368
+ print(f"ERROR {p.path.relative_to(target)}: {p.message}")
369
+ print(f"\nAdoption produced {len(problems)} validation error(s) to resolve.")
370
+ return 1
371
+ print("\nAdopted repository validates as a conformant RepoPact.")
372
+ return 0
373
+
374
+
375
+ if __name__ == "__main__":
376
+ sys.exit(main())
@@ -0,0 +1,125 @@
1
+ """Diff-time enforcement for the declared frozen surface (INV-6).
2
+
3
+ The validator reasons about records, not diffs, so it cannot tell whether a given
4
+ change touched a protected path or symbol. This script is the backstop: it compares
5
+ a diff range against ``governance/frozen-surface.json`` and reports protected paths
6
+ that changed and protected symbols that appear in the patch.
7
+
8
+ It is best-effort: outside a git checkout it reports nothing and exits zero, so it
9
+ never blocks environments where a diff is unavailable.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import fnmatch
16
+ import re
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from repo_model import load_json
22
+
23
+
24
+ # Each entry is a diff range/flags that exposes a class of change: committed branch
25
+ # changes (base...HEAD), plus all uncommitted working-tree and staged changes vs
26
+ # HEAD. Unioning them means the CI gate (clean tree) still sees exactly the branch's
27
+ # commits, while a local pre-commit run also catches edits that have not been
28
+ # committed yet (finding F-002 from the proving ground).
29
+ def _ranges(base: str) -> list[list[str]]:
30
+ return [[f"{base}...HEAD"], ["HEAD"]]
31
+
32
+
33
+ def changed_files(root: Path, base: str) -> list[str] | None:
34
+ seen: dict[str, None] = {}
35
+ saw_any = False
36
+ for rng in _ranges(base):
37
+ out = _git(root, ["diff", "--name-only", *rng])
38
+ if out is None:
39
+ continue
40
+ saw_any = True
41
+ for line in out:
42
+ seen.setdefault(line, None)
43
+ return list(seen) if saw_any else None
44
+
45
+
46
+ def diff_text(root: Path, base: str) -> str | None:
47
+ parts: list[str] = []
48
+ saw_any = False
49
+ for rng in _ranges(base):
50
+ out = _git(root, ["diff", "--unified=0", *rng])
51
+ if out is None:
52
+ continue
53
+ saw_any = True
54
+ parts.append("\n".join(out))
55
+ return "\n".join(parts) if saw_any else None
56
+
57
+
58
+ def _git(root: Path, args: list[str]) -> list[str] | None:
59
+ try:
60
+ result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=True)
61
+ except (OSError, subprocess.CalledProcessError):
62
+ return None
63
+ return [line for line in result.stdout.splitlines()]
64
+
65
+
66
+ def path_hits(files: list[str], protected: list[dict]) -> list[tuple[str, str]]:
67
+ hits: list[tuple[str, str]] = []
68
+ for path in files:
69
+ for entry in protected:
70
+ glob = str(entry.get("glob", ""))
71
+ if fnmatch.fnmatch(path, glob) or fnmatch.fnmatch(path, f"{glob.rstrip('/*')}/**"):
72
+ hits.append((path, str(entry.get("reason", ""))))
73
+ return hits
74
+
75
+
76
+ def symbol_hits(patch: str, protected: list[dict]) -> list[tuple[str, str]]:
77
+ """Protected symbols that appear on added or removed lines of the patch."""
78
+ changed_lines = [
79
+ line[1:] for line in patch.splitlines()
80
+ if line[:1] in {"+", "-"} and not line.startswith(("+++", "---"))
81
+ ]
82
+ body = "\n".join(changed_lines)
83
+ hits: list[tuple[str, str]] = []
84
+ for entry in protected:
85
+ for symbol in entry.get("symbols", []):
86
+ if re.search(rf"\b{re.escape(str(symbol))}\b", body):
87
+ hits.append((str(symbol), str(entry.get("reason", ""))))
88
+ return hits
89
+
90
+
91
+ def violations(root: Path, base: str) -> list[tuple[str, str]]:
92
+ protected = load_json(root / "governance" / "frozen-surface.json").get("protected", [])
93
+ files = changed_files(root, base)
94
+ if files is None:
95
+ return []
96
+ hits = path_hits(files, protected)
97
+ patch = diff_text(root, base)
98
+ if patch:
99
+ hits.extend(symbol_hits(patch, protected))
100
+ return hits
101
+
102
+
103
+ def main() -> int:
104
+ parser = argparse.ArgumentParser(description="Detect frozen-surface changes in a diff range")
105
+ parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1])
106
+ parser.add_argument("--base", default="origin/main", help="Base ref to diff against")
107
+ parser.add_argument("--ack", action="store_true", help="Acknowledge operator approval for the change")
108
+ args = parser.parse_args()
109
+ root = args.root.resolve()
110
+ hits = violations(root, args.base)
111
+ if not hits:
112
+ print("No frozen-surface changes detected.")
113
+ return 0
114
+ print("Frozen-surface changes detected (INV-6 requires operator approval):")
115
+ for name, reason in hits:
116
+ print(f" {name}: {reason}")
117
+ if args.ack:
118
+ print("\nOperator approval acknowledged (--ack). Proceeding.")
119
+ return 0
120
+ print("\nRe-run with --ack only after a human operator has approved these changes.")
121
+ return 1
122
+
123
+
124
+ if __name__ == "__main__":
125
+ sys.exit(main())