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.
- adopt_repo.py +376 -0
- check_frozen_surface.py +125 -0
- doctor.py +242 -0
- frontmatter.py +48 -0
- generate_dashboard.py +109 -0
- generate_spec.py +92 -0
- init_repo.py +145 -0
- new.py +87 -0
- plan_import.py +320 -0
- repo_model.py +67 -0
- repopact-1.4.0.data/data/share/repopact/schemas/audit-finding.schema.json +15 -0
- repopact-1.4.0.data/data/share/repopact/schemas/evidence-run.schema.json +16 -0
- repopact-1.4.0.data/data/share/repopact/schemas/frozen-surface.schema.json +21 -0
- repopact-1.4.0.data/data/share/repopact/schemas/invariants.schema.json +27 -0
- repopact-1.4.0.data/data/share/repopact/schemas/record-frontmatter.schema.json +29 -0
- repopact-1.4.0.data/data/share/repopact/schemas/work-item.schema.json +31 -0
- repopact-1.4.0.data/data/share/repopact/templates/README.md +14 -0
- repopact-1.4.0.data/data/share/repopact/templates/decision.md +25 -0
- repopact-1.4.0.data/data/share/repopact/templates/evidence-run.json +12 -0
- repopact-1.4.0.data/data/share/repopact/templates/policy.md +20 -0
- repopact-1.4.0.data/data/share/repopact/templates/work-item.README.md +24 -0
- repopact-1.4.0.data/data/share/repopact/templates/work-item.json +14 -0
- repopact-1.4.0.dist-info/METADATA +125 -0
- repopact-1.4.0.dist-info/RECORD +32 -0
- repopact-1.4.0.dist-info/WHEEL +5 -0
- repopact-1.4.0.dist-info/entry_points.txt +2 -0
- repopact-1.4.0.dist-info/licenses/LICENSE +201 -0
- repopact-1.4.0.dist-info/top_level.txt +14 -0
- repopact_cli.py +194 -0
- takeover.py +143 -0
- track_import.py +204 -0
- 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())
|
check_frozen_surface.py
ADDED
|
@@ -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())
|