gisel-ab 0.9.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 (107) hide show
  1. gisel_ab/__init__.py +120 -0
  2. gisel_ab/__main__.py +8 -0
  3. gisel_ab/baseline.py +145 -0
  4. gisel_ab/bugreport.py +113 -0
  5. gisel_ab/cli.py +150 -0
  6. gisel_ab/cohort.py +704 -0
  7. gisel_ab/cpp/aarch64_generated_ctor.cpp +23 -0
  8. gisel_ab/cpp/aarch64_query_runner.cpp +380 -0
  9. gisel_ab/cpp/arm_generated_ctor.cpp +18 -0
  10. gisel_ab/cpp/arm_query_runner.cpp +58 -0
  11. gisel_ab/cpp/m68k_generated_ctor.cpp +21 -0
  12. gisel_ab/cpp/m68k_query_runner.cpp +43 -0
  13. gisel_ab/cpp/mips_generated_ctor.cpp +16 -0
  14. gisel_ab/cpp/mips_query_runner.cpp +52 -0
  15. gisel_ab/cpp/ppc_generated_ctor.cpp +16 -0
  16. gisel_ab/cpp/ppc_query_runner.cpp +46 -0
  17. gisel_ab/cpp/query-recovery.h +229 -0
  18. gisel_ab/cpp/query_runner.cpp +419 -0
  19. gisel_ab/cpp/query_runner_common.h +417 -0
  20. gisel_ab/cpp/riscv_generated_ctor.cpp +25 -0
  21. gisel_ab/cpp/riscv_query_runner.cpp +379 -0
  22. gisel_ab/cpp/spirv_generated_ctor.cpp +24 -0
  23. gisel_ab/cpp/spirv_query_runner.cpp +98 -0
  24. gisel_ab/cpp/wasm_generated_ctor.cpp +17 -0
  25. gisel_ab/cpp/wasm_query_runner.cpp +52 -0
  26. gisel_ab/cpp/x86_generated_ctor.cpp +24 -0
  27. gisel_ab/data/aarch64-contract.json +102 -0
  28. gisel_ab/data/baseline-2026-11-12.json +1684 -0
  29. gisel_ab/data/closure/aarch64-D.json +156 -0
  30. gisel_ab/data/closure/aarch64-G.json +75 -0
  31. gisel_ab/data/closure/arm-D.json +50 -0
  32. gisel_ab/data/closure/arm-G.json +57 -0
  33. gisel_ab/data/closure/m68k-D.json +46 -0
  34. gisel_ab/data/closure/m68k-G.json +54 -0
  35. gisel_ab/data/closure/mips-D.json +50 -0
  36. gisel_ab/data/closure/mips-G.json +87 -0
  37. gisel_ab/data/closure/ppc-D.json +49 -0
  38. gisel_ab/data/closure/ppc-G.json +56 -0
  39. gisel_ab/data/closure/riscv-D.json +59 -0
  40. gisel_ab/data/closure/riscv-G.json +66 -0
  41. gisel_ab/data/closure/spirv-D.json +58 -0
  42. gisel_ab/data/closure/spirv-G.json +58 -0
  43. gisel_ab/data/closure/wasm-D.json +56 -0
  44. gisel_ab/data/closure/wasm-G.json +62 -0
  45. gisel_ab/data/closure/x86-D.json +60 -0
  46. gisel_ab/data/closure/x86-G.json +66 -0
  47. gisel_ab/data/domain.json +139 -0
  48. gisel_ab/data/mir-configs.json +444 -0
  49. gisel_ab/data/mir-shapes.jsonc +1168 -0
  50. gisel_ab/data/riscv-contract.json +102 -0
  51. gisel_ab/data/suite/README.md +81 -0
  52. gisel_ab/data/suite/aarch64/official.answers.jsonl +12559 -0
  53. gisel_ab/data/suite/aarch64/queries.jsonl +12559 -0
  54. gisel_ab/data/suite/aarch64/suite.json +43 -0
  55. gisel_ab/data/suite/m68k/official.answers.jsonl +35 -0
  56. gisel_ab/data/suite/m68k/queries.jsonl +35 -0
  57. gisel_ab/data/suite/m68k/suite.json +24 -0
  58. gisel_ab/data/suite/riscv/official.answers.jsonl +6086 -0
  59. gisel_ab/data/suite/riscv/queries.jsonl +6086 -0
  60. gisel_ab/data/suite/riscv/suite.json +41 -0
  61. gisel_ab/data/suite/x86/official.answers.jsonl +3014 -0
  62. gisel_ab/data/suite/x86/queries.jsonl +3014 -0
  63. gisel_ab/data/suite/x86/suite.json +33 -0
  64. gisel_ab/data/targets/aarch64.json +39 -0
  65. gisel_ab/data/targets/arm.json +39 -0
  66. gisel_ab/data/targets/m68k.json +39 -0
  67. gisel_ab/data/targets/mips.json +39 -0
  68. gisel_ab/data/targets/ppc.json +39 -0
  69. gisel_ab/data/targets/riscv.json +39 -0
  70. gisel_ab/data/targets/schema.json +266 -0
  71. gisel_ab/data/targets/spirv.json +39 -0
  72. gisel_ab/data/targets/wasm.json +39 -0
  73. gisel_ab/data/targets/x86-ctor.json +39 -0
  74. gisel_ab/data/targets/x86.json +39 -0
  75. gisel_ab/data/x86-contract.json +93 -0
  76. gisel_ab/doctor.py +320 -0
  77. gisel_ab/domain_plan.py +130 -0
  78. gisel_ab/evidence.py +892 -0
  79. gisel_ab/l0/__init__.py +0 -0
  80. gisel_ab/l0/attribute.py +286 -0
  81. gisel_ab/l0/closure.py +255 -0
  82. gisel_ab/l0/diff.py +388 -0
  83. gisel_ab/l0/g_attribution.py +93 -0
  84. gisel_ab/l0/inject_opcode.py +123 -0
  85. gisel_ab/l0/inject_predicate.py +150 -0
  86. gisel_ab/l0/queries.py +215 -0
  87. gisel_ab/l0/run.py +688 -0
  88. gisel_ab/l0/swap.py +257 -0
  89. gisel_ab/l0/u_attribution.py +192 -0
  90. gisel_ab/l3.py +271 -0
  91. gisel_ab/mir/__init__.py +0 -0
  92. gisel_ab/mir/config_check.py +144 -0
  93. gisel_ab/mir/from_query.py +717 -0
  94. gisel_ab/mir/libs.py +252 -0
  95. gisel_ab/mir/replay.py +548 -0
  96. gisel_ab/paths.py +151 -0
  97. gisel_ab/refresh_counts.py +80 -0
  98. gisel_ab/replay_frozen.py +154 -0
  99. gisel_ab/setup.py +92 -0
  100. gisel_ab/suite.py +390 -0
  101. gisel_ab/targets.py +287 -0
  102. gisel_ab/vertical.py +654 -0
  103. gisel_ab-0.9.0.dist-info/METADATA +349 -0
  104. gisel_ab-0.9.0.dist-info/RECORD +107 -0
  105. gisel_ab-0.9.0.dist-info/WHEEL +5 -0
  106. gisel_ab-0.9.0.dist-info/entry_points.txt +2 -0
  107. gisel_ab-0.9.0.dist-info/top_level.txt +1 -0
gisel_ab/cohort.py ADDED
@@ -0,0 +1,704 @@
1
+ #!/usr/bin/env python3
2
+ """One cohort, from tag to a re-checkable record.
3
+
4
+ cohort.py plan [--online] [--json]
5
+ cohort.py tag NAME [--push]
6
+ cohort.py verify --clone DIR [--out DIR] [--only NAME,...] [--jobs N]
7
+ cohort.py record-ci --file CI.json
8
+ cohort.py check [--worktree] [--json]
9
+
10
+ A cohort is three commits (gisel, gisel-anchor, gisel-ab) and a set of gates.
11
+ `RELEASE.json` names the commits and the gate commands; this file adds what
12
+ the manifest deliberately does not carry: which repo each gate runs in, which
13
+ files are its inputs, and a fingerprint over those files.
14
+
15
+ `verify` runs the gates in clean clones at the tags and writes a draft
16
+ record. `check` proves that a record still matches the commits it names;
17
+ `check --worktree` compares it with the current worktrees instead, which is
18
+ how a changed corpus shows up as a STALE gate before anyone tags again.
19
+
20
+ The record commit is a child of the cohort tag, so at the tag itself the
21
+ checked-in VERIFICATION.json still names the previous cohort. `check` skips
22
+ that state with rc=0 and says so; only a record that names the current
23
+ cohort is evidence about the current cohort.
24
+
25
+ Exit codes: 0 = ok; 1 = a gate failed / a fingerprint is stale; 2 = usage,
26
+ missing manifest, dirty tree, or a tag that already exists.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import hashlib
33
+ import json
34
+ import os
35
+ import pathlib
36
+ import shutil
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+ import time
41
+ from concurrent.futures import ThreadPoolExecutor
42
+
43
+ from gisel_ab import paths
44
+
45
+ PKG = pathlib.Path(__file__).resolve().parent
46
+ AB = PKG.parents[1]
47
+ GISEL = pathlib.Path(os.environ.get("GISEL_SRC", AB.parent / "gisel"))
48
+ UPSTREAM = paths.sibling(AB.parent, "UPSTREAM_SOURCE",
49
+ "gisel-anchor", "upstream-source")
50
+ MANIFEST = AB / "RELEASE.json"
51
+ VERIFICATION = AB / "VERIFICATION.json"
52
+
53
+ # A gate command from RELEASE.json -> where it runs and what it reads.
54
+ # Cross-repo inputs are written "repo:pathspec"; pathspecs are passed to
55
+ # `git ls-tree` / `git ls-files`, so globs and directories both work.
56
+ VERIFY_EVIDENCE = {
57
+ "repo": "ab",
58
+ "command": "./scripts/verify-evidence.sh",
59
+ "inputs": ["EVIDENCE.json", "src/gisel_ab/evidence.py",
60
+ "evidence.schema.json", "evidence/**/*",
61
+ "src/gisel_ab/data/**/*", "scripts/**/*",
62
+ "gisel:tests/artifacts/**/*"],
63
+ }
64
+
65
+ # r7 and earlier spelled every workbench path from the repository root. The
66
+ # productization moved code under src/ and scripts/; the old spellings stay
67
+ # here so an old cohort can still be replayed against its own layout.
68
+ VERIFY_EVIDENCE_ROOTED = {
69
+ "repo": "ab",
70
+ "command": "./verify-evidence.sh",
71
+ "inputs": ["EVIDENCE.json", "evidence.py", "evidence.schema.json",
72
+ "oracle/**/*", "data/**/*", "*.sh", "gisel:tests/artifacts/**/*"],
73
+ }
74
+
75
+ GATES = {
76
+ "python3 -m unittest discover -q -s tests": {
77
+ "repo": "gisel",
78
+ "inputs": ["gisel/*.py", "gisel/**/*.py", "tests/**/*", "spec/**/*"],
79
+ },
80
+ "bash tools/check-nine-corpora.sh": {
81
+ "repo": "gisel",
82
+ "inputs": ["tests/corpus/*.gisel", "tests/artifacts/**/*",
83
+ "gisel/**/*.py", "tools/**/*"],
84
+ },
85
+ "bash tools/check-graph-golden.sh": {
86
+ "repo": "gisel",
87
+ "inputs": ["tests/corpus/*.gisel", "tests/cases/*.json",
88
+ "tests/artifacts/graph/**/*", "gisel/**/*.py", "tools/**/*",
89
+ "spec/graph.md"],
90
+ },
91
+ "bash tools/check-cases-golden.sh": {
92
+ "repo": "gisel",
93
+ "inputs": ["tests/corpus/*.gisel", "tests/cases/*.json",
94
+ "tests/artifacts/cases/**/*", "gisel/**/*.py", "tools/**/*",
95
+ "spec/cases-format.md"],
96
+ },
97
+ "bash tools/check-nightly-graphs.sh": {
98
+ "repo": "gisel",
99
+ "inputs": ["tests/corpus/*.gisel", "tests/cases/*.json",
100
+ "tests/artifacts/graph/**/*", "gisel/**/*.py", "tools/**/*"],
101
+ "slow": True,
102
+ },
103
+ "python3 tools/check-claims.py": {
104
+ "repo": "gisel",
105
+ "inputs": ["docs/**/*.md", "gisel/**/*.py", "tools/**/*", "spec/**/*"],
106
+ },
107
+ "python3 importer/import-cxx.py --all --pin pin/PIN.json --check": {
108
+ "repo": "upstream_source",
109
+ "inputs": ["importer/**/*", "pin/**/*", "oracle/**/*",
110
+ "gisel:tests/corpus/*.gisel"],
111
+ "expand": "import-check",
112
+ },
113
+ "pin/make-base.sh TREE DEST --verify": {
114
+ "repo": "upstream_source",
115
+ "inputs": ["pin/**/*"],
116
+ "expand": "make-base",
117
+ },
118
+ # r7 moved the anchor's entry points into scripts/ (the code itself lives
119
+ # under src/gisel_anchor). The r6 spellings above stay for replaying r6.
120
+ "python3 scripts/import-cxx.py --all --pin pin/PIN.json --check": {
121
+ "repo": "upstream_source",
122
+ "inputs": ["src/**/*", "scripts/**/*", "pin/**/*",
123
+ "gisel:tests/corpus/*.gisel"],
124
+ "expand": "import-check",
125
+ },
126
+ "scripts/pin/make-base.sh TREE DEST --verify": {
127
+ "repo": "upstream_source",
128
+ "inputs": ["pin/**/*", "scripts/pin/**/*"],
129
+ "expand": "make-base",
130
+ },
131
+ # r5 names the harness by its repository name; r4 and earlier manifests
132
+ # said `ab/verify-evidence.sh`. Both point at the same gate.
133
+ "gisel-ab/scripts/verify-evidence.sh": VERIFY_EVIDENCE,
134
+ "gisel-ab/verify-evidence.sh": VERIFY_EVIDENCE_ROOTED,
135
+ "ab/verify-evidence.sh": VERIFY_EVIDENCE_ROOTED,
136
+ }
137
+
138
+ # Gates the workbench runs on itself but does not put in RELEASE.json: the
139
+ # manifest names product evidence, not the harness's own unit suite.
140
+ WORKBENCH_GATES = {
141
+ "python3 -m pytest tests -q": {
142
+ "repo": "ab",
143
+ "inputs": ["tests/**/*", "src/**/*", "scripts/**/*", "evidence/**/*"],
144
+ },
145
+ }
146
+
147
+ ONLINE_GATES = {
148
+ "adopt-check": {
149
+ "repo": "ab",
150
+ "command": "./scripts/adopt-check.sh",
151
+ "inputs": ["EVIDENCE.json", "src/gisel_ab/evidence.py",
152
+ "gisel:tests/artifacts/*.inc"],
153
+ "online": True,
154
+ },
155
+ }
156
+
157
+ REPO_DIRS = {"gisel": GISEL, "upstream_source": UPSTREAM, "ab": AB}
158
+
159
+
160
+ def fail(message, rc=2):
161
+ print("cohort: %s" % message, file=sys.stderr)
162
+ return rc
163
+
164
+
165
+ def git(repo, *args, input_text=None):
166
+ return subprocess.run(["git", "-C", str(repo), *args],
167
+ capture_output=True, text=True, input=input_text)
168
+
169
+
170
+ def head(repo):
171
+ found = git(repo, "rev-parse", "HEAD")
172
+ if found.returncode:
173
+ raise SystemExit("cohort: %s is not a git checkout: %s"
174
+ % (repo, found.stderr.strip()))
175
+ return found.stdout.strip()
176
+
177
+
178
+ def dirty(repo):
179
+ found = git(repo, "status", "--porcelain")
180
+ return bool(found.stdout.strip())
181
+
182
+
183
+ def load_manifest():
184
+ if not MANIFEST.is_file():
185
+ raise SystemExit("cohort: missing %s" % MANIFEST)
186
+ return json.loads(MANIFEST.read_text())
187
+
188
+
189
+ def split_input(spec):
190
+ if ":" in spec:
191
+ repo, path = spec.split(":", 1)
192
+ return repo, path
193
+ return None, spec
194
+
195
+
196
+ def gate_table(manifest, online=False):
197
+ """RELEASE gates plus the workbench's extra checks, with their inputs."""
198
+ table = []
199
+ for command in manifest["gates"]:
200
+ if command not in GATES:
201
+ raise SystemExit("cohort: no descriptor for gate %r; add it to "
202
+ "cohort.py GATES" % command)
203
+ spec = GATES[command]
204
+ table.append({
205
+ "name": spec.get("command", command),
206
+ "manifest_command": command,
207
+ "repo": spec["repo"],
208
+ "command": spec.get("command", command),
209
+ "inputs": spec["inputs"],
210
+ "slow": bool(spec.get("slow")),
211
+ "online": False,
212
+ "expand": spec.get("expand"),
213
+ })
214
+ for command, spec in WORKBENCH_GATES.items():
215
+ table.append({
216
+ "name": command,
217
+ "manifest_command": None,
218
+ "repo": spec["repo"],
219
+ "command": command,
220
+ "inputs": spec["inputs"],
221
+ "slow": False,
222
+ "online": False,
223
+ "expand": None,
224
+ })
225
+ if online:
226
+ for name, spec in ONLINE_GATES.items():
227
+ table.append({
228
+ "name": name,
229
+ "manifest_command": None,
230
+ "repo": spec["repo"],
231
+ "command": spec["command"],
232
+ "inputs": spec["inputs"],
233
+ "slow": False,
234
+ "online": True,
235
+ "expand": None,
236
+ })
237
+ return table
238
+
239
+
240
+ def _repo_path(name):
241
+ if name == "gisel":
242
+ return GISEL
243
+ if name == "upstream_source":
244
+ return UPSTREAM
245
+ if name == "ab":
246
+ return AB
247
+ raise SystemExit("cohort: unknown repo %r" % name)
248
+
249
+
250
+ _CACHE = {}
251
+
252
+
253
+ def _matches(path, spec):
254
+ """git pathspec-ish match, with ** meaning "any depth, including zero".
255
+
256
+ ls-tree does not expand `*`, so the listing is done once per revision and
257
+ filtered here; fnmatch treats `*` as matching `/` too, which is the
258
+ conservative direction for an input fingerprint.
259
+ """
260
+ import fnmatch
261
+ if fnmatch.fnmatchcase(path, spec):
262
+ return True
263
+ if "/**/" in spec:
264
+ return fnmatch.fnmatchcase(path, spec.replace("/**/", "/"))
265
+ return False
266
+
267
+
268
+ def _committed_map(repo, rev):
269
+ key = ("commit", str(repo), rev)
270
+ if key in _CACHE:
271
+ return _CACHE[key]
272
+ found = git(repo, "ls-tree", "-r", "-z", rev)
273
+ if found.returncode:
274
+ raise SystemExit("cohort: ls-tree %s failed: %s"
275
+ % (repo, found.stderr.strip()))
276
+ rows = {}
277
+ for record in found.stdout.split("\0"):
278
+ if not record:
279
+ continue
280
+ meta, path = record.split("\t", 1)
281
+ fields = meta.split()
282
+ if len(fields) == 3 and fields[1] == "blob":
283
+ rows[path] = fields[2]
284
+ _CACHE[key] = rows
285
+ return rows
286
+
287
+
288
+ def _worktree_map(repo):
289
+ key = ("worktree", str(repo))
290
+ if key in _CACHE:
291
+ return _CACHE[key]
292
+ found = git(repo, "ls-files", "-z", "--cached", "--others",
293
+ "--exclude-standard")
294
+ if found.returncode:
295
+ raise SystemExit("cohort: ls-files %s failed: %s"
296
+ % (repo, found.stderr.strip()))
297
+ paths = sorted({p for p in found.stdout.split("\0") if p})
298
+ blobs = {}
299
+ if paths:
300
+ hashed = git(repo, "hash-object", "--stdin-paths",
301
+ input_text="\n".join(str(repo / p) for p in paths) + "\n")
302
+ if hashed.returncode:
303
+ raise SystemExit("cohort: hash-object failed in %s: %s"
304
+ % (repo, hashed.stderr.strip()))
305
+ digests = hashed.stdout.split()
306
+ if len(digests) != len(paths):
307
+ raise SystemExit("cohort: hash-object returned %d digests for %d "
308
+ "paths" % (len(digests), len(paths)))
309
+ blobs = dict(zip(paths, digests))
310
+ _CACHE[key] = blobs
311
+ return blobs
312
+
313
+
314
+ def _select(blobs, spec):
315
+ return {path: blob for path, blob in blobs.items() if _matches(path, spec)}
316
+
317
+
318
+ def _blob_map(gate, commits=None, worktree=False):
319
+ rows = {}
320
+ for spec in gate["inputs"]:
321
+ repo_name, pathspec = split_input(spec)
322
+ if repo_name is None:
323
+ repo_name = gate["repo"]
324
+ repo = _repo_path(repo_name)
325
+ source = (_worktree_map(repo) if worktree
326
+ else _committed_map(repo, commits[repo_name]))
327
+ for path, blob in _select(source, pathspec).items():
328
+ rows["%s:%s" % (repo_name, path)] = blob
329
+ return rows
330
+
331
+
332
+ def fingerprint(gate, commits=None, worktree=False):
333
+ """sha256 over (path, blob) for one gate's inputs.
334
+
335
+ With `commits`, read the committed blobs at those revs; with `worktree`,
336
+ hash the files on disk (tracked and untracked). Both sides are git blob
337
+ ids of file content, so the two modes are comparable for a clean tree.
338
+ """
339
+ rows = _blob_map(gate, commits=commits, worktree=worktree)
340
+ lines = ["%s\0%s\n" % (path, blob) for path, blob in sorted(rows.items())]
341
+ return hashlib.sha256("".join(lines).encode()).hexdigest()
342
+
343
+
344
+ def changed_inputs(gate, record, worktree=True):
345
+ """Files whose blob differs between the record and a checkout."""
346
+ expected = _blob_map(gate, commits=record.get("gate_commits",
347
+ record["commits"]),
348
+ worktree=worktree)
349
+ stored = None
350
+ for item in record["gates"]:
351
+ if item.get("name") == gate["name"]:
352
+ stored = item.get("blobs")
353
+ if stored is None:
354
+ return sorted(expected)[:5]
355
+ changed = [key for key, blob in expected.items() if stored.get(key) != blob]
356
+ return sorted(changed)[:5]
357
+
358
+
359
+ def _expand_command(gate, clone_root, out_dir):
360
+ command = gate["command"]
361
+ if gate.get("expand") == "make-base":
362
+ tree = paths.require_tree()
363
+ dest = pathlib.Path(out_dir) / "pin-base"
364
+ if dest.exists():
365
+ shutil.rmtree(dest)
366
+ command = gate["command"].replace("TREE", str(tree)).replace(
367
+ "DEST", str(dest))
368
+ elif gate.get("expand") == "import-check":
369
+ llvm = paths.llvm_repo(
370
+ fallback=os.environ.get("COHORT_TREE")
371
+ or os.environ.get("AB_TREE"))
372
+ command = command + " --git-repo %s" % llvm
373
+ return command
374
+
375
+
376
+ def _last_line(text, limit=200):
377
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
378
+ if not lines:
379
+ return ""
380
+ line = lines[-1]
381
+ return line if len(line) <= limit else line[:limit - 3] + "..."
382
+
383
+
384
+ def _run_one(gate, clones, out_dir, timeout):
385
+ repo = pathlib.Path(clones) / gate["repo"]
386
+ command = _expand_command(gate, clones, out_dir)
387
+ env = dict(os.environ)
388
+ env.update({
389
+ "GISEL_SRC": str(pathlib.Path(clones) / "gisel"),
390
+ "AB_SRC": str(pathlib.Path(clones) / "ab"),
391
+ "UPSTREAM_SOURCE": str(pathlib.Path(clones) / "upstream_source"),
392
+ "PYTHONDONTWRITEBYTECODE": "1",
393
+ })
394
+ if "verify-evidence.sh" in gate["name"]:
395
+ env["VERIFY_FROZEN"] = "1"
396
+ started = time.time()
397
+ try:
398
+ found = subprocess.run(["bash", "-lc", command], cwd=str(repo),
399
+ capture_output=True, text=True,
400
+ env=env, timeout=timeout)
401
+ rc, stdout, stderr = found.returncode, found.stdout, found.stderr
402
+ except subprocess.TimeoutExpired as exc:
403
+ rc = 124
404
+ stdout = (exc.stdout or "") if isinstance(exc.stdout, str) else ""
405
+ stderr = "timeout after %ss" % timeout
406
+ elapsed = round(time.time() - started, 1)
407
+ if gate.get("expand") == "make-base":
408
+ tree = paths.require_tree()
409
+ dest = pathlib.Path(out_dir) / "pin-base"
410
+ subprocess.run(["git", "-C", tree, "worktree", "remove", "--force",
411
+ str(dest)], capture_output=True, text=True)
412
+ if dest.exists():
413
+ shutil.rmtree(dest, ignore_errors=True)
414
+ log = pathlib.Path(out_dir) / ("%s.log" % gate["name"].replace("/", "_"))
415
+ log.parent.mkdir(parents=True, exist_ok=True)
416
+ log.write_text("$ %s\n\n[stdout]\n%s\n[stderr]\n%s\n"
417
+ % (command, stdout, stderr))
418
+ return {
419
+ "name": gate["name"],
420
+ "repo": gate["repo"],
421
+ "manifest_command": gate.get("manifest_command"),
422
+ "command": command,
423
+ "rc": rc,
424
+ "evidence": _last_line(stdout) or _last_line(stderr),
425
+ "duration_s": elapsed,
426
+ "log": str(log),
427
+ }
428
+
429
+
430
+ def cmd_plan(args):
431
+ manifest = load_manifest()
432
+ table = gate_table(manifest, online=args.online)
433
+ rows = []
434
+ for gate in table:
435
+ rows.append({
436
+ "name": gate["name"],
437
+ "repo": gate["repo"],
438
+ "command": gate["command"],
439
+ "inputs": gate["inputs"],
440
+ "slow": gate["slow"],
441
+ "online": gate["online"],
442
+ })
443
+ if args.json:
444
+ print(json.dumps({"cohort": manifest["cohort"], "gates": rows},
445
+ indent=2, sort_keys=True))
446
+ else:
447
+ print("cohort: %s" % manifest["cohort"])
448
+ for gate in rows:
449
+ flags = " slow" if gate["slow"] else ""
450
+ flags += " online" if gate["online"] else ""
451
+ print(" %-10s %-52s%s" % (gate["repo"], gate["name"], flags))
452
+ print(" inputs: %s" % ", ".join(gate["inputs"]))
453
+ return 0
454
+
455
+
456
+ def cmd_tag(args):
457
+ manifest = load_manifest()
458
+ if args.name != manifest["cohort"]:
459
+ return fail("tag %r does not match RELEASE.json.cohort %r; regenerate "
460
+ "the manifest first" % (args.name, manifest["cohort"]))
461
+ repos = {"gisel": GISEL, "upstream_source": UPSTREAM, "ab": AB}
462
+ for name, repo in repos.items():
463
+ if dirty(repo):
464
+ return fail("%s has uncommitted changes; commit before tagging"
465
+ % name)
466
+ if head(GISEL) != manifest["repos"]["gisel"]:
467
+ return fail("gisel HEAD is not the manifest commit")
468
+ if head(UPSTREAM) != manifest["repos"]["upstream_source"]:
469
+ return fail("gisel-anchor HEAD is not the manifest commit")
470
+ if head(AB) != manifest["repos"]["ab"]:
471
+ manifest_edit = git(AB, "log", "-1", "--format=%H", "--", "RELEASE.json")
472
+ if manifest_edit.stdout.strip() != head(AB):
473
+ return fail("ab HEAD is neither repos.ab nor the manifest commit")
474
+ parent = git(AB, "rev-parse", head(AB) + "^").stdout.strip()
475
+ if parent != manifest["repos"]["ab"]:
476
+ return fail("the manifest commit did not add only RELEASE.json")
477
+ for name, repo in repos.items():
478
+ existing = git(repo, "tag", "--list", args.name)
479
+ if existing.stdout.strip():
480
+ return fail("%s already has tag %s; do not rewrite it, use the "
481
+ "next -rN" % (name, args.name))
482
+ for name, repo in repos.items():
483
+ found = git(repo, "-c", "user.name=cohort",
484
+ "-c", "user.email=cohort@example.invalid",
485
+ "tag", "-a", args.name, "-m",
486
+ "cohort %s" % args.name, head(repo))
487
+ if found.returncode:
488
+ return fail("%s: %s" % (name, found.stderr.strip()))
489
+ print("tagged %-16s %s" % (name, head(repo)))
490
+ if args.push:
491
+ for name, repo in repos.items():
492
+ found = git(repo, "push", "origin", args.name)
493
+ if found.returncode:
494
+ return fail("%s: %s" % (name, found.stderr.strip()))
495
+ print("pushed %-16s %s" % (name, args.name))
496
+ return 0
497
+
498
+
499
+ def _clone(repo, dest, rev):
500
+ found = git(repo, "clone", "-q", "--no-hardlinks", str(repo), str(dest))
501
+ if found.returncode:
502
+ return fail("clone %s: %s" % (repo, found.stderr.strip()))
503
+ found = subprocess.run(["git", "-C", str(dest), "checkout", "-q", rev],
504
+ capture_output=True, text=True)
505
+ if found.returncode:
506
+ return fail("checkout %s in %s: %s"
507
+ % (rev, dest, found.stderr.strip()))
508
+ return 0
509
+
510
+
511
+ def cmd_verify(args):
512
+ manifest = load_manifest()
513
+ table = gate_table(manifest)
514
+ if args.only:
515
+ wanted = {name.strip() for name in args.only.split(",")}
516
+ table = [gate for gate in table if gate["name"] in wanted
517
+ or gate["command"] in wanted]
518
+ missing = wanted - {gate["name"] for gate in table} - {gate["command"] for gate in table}
519
+ if missing:
520
+ return fail("unknown gate(s): %s" % ", ".join(sorted(missing)))
521
+ clone_root = pathlib.Path(args.clone)
522
+ clone_root.mkdir(parents=True, exist_ok=True)
523
+ manifest_commit = git(AB, "log", "-1", "--format=%H", "--",
524
+ "RELEASE.json").stdout.strip()
525
+ if not manifest_commit:
526
+ return fail("RELEASE.json is not committed")
527
+ # The manifest commit adds only RELEASE.json, but that file is an input
528
+ # to the manifest tests; gate at the tag commit, not its parent.
529
+ commits = dict(manifest["repos"])
530
+ commits["ab"] = manifest_commit
531
+ for name, rev in (("gisel", commits["gisel"]),
532
+ ("upstream_source", commits["upstream_source"]),
533
+ ("ab", commits["ab"])):
534
+ dest = clone_root / name
535
+ if not dest.exists() or args.reclone:
536
+ if dest.exists():
537
+ shutil.rmtree(dest)
538
+ rc = _clone(_repo_path(name), dest, rev)
539
+ if rc:
540
+ return rc
541
+ got = git(dest, "rev-parse", "HEAD").stdout.strip()
542
+ if got != rev:
543
+ return fail("%s clone is at %s, expected %s" % (name, got[:12], rev[:12]))
544
+ out = pathlib.Path(args.out or (clone_root / "record"))
545
+ out.mkdir(parents=True, exist_ok=True)
546
+ results = []
547
+ with ThreadPoolExecutor(max_workers=args.jobs) as pool:
548
+ futures = [pool.submit(_run_one, gate, clone_root, out,
549
+ args.timeout) for gate in table]
550
+ for gate, future in zip(table, futures):
551
+ result = future.result()
552
+ result["fingerprint"] = fingerprint(gate, commits=commits)
553
+ result["blobs"] = _blob_map(gate, commits=commits)
554
+ results.append(result)
555
+ print("%-6s %-10s %-48s %6.1fs %s"
556
+ % ("ok" if result["rc"] == 0 else "FAIL",
557
+ result["repo"], result["name"],
558
+ result["duration_s"], result["evidence"]))
559
+ payload = {
560
+ "schema": 2,
561
+ "cohort": manifest["cohort"],
562
+ "manifest_commit": manifest_commit,
563
+ "tags": {name: manifest["cohort"]
564
+ for name in ("gisel", "upstream_source", "ab")},
565
+ "commits": commits,
566
+ "gate_commits": commits,
567
+ "gates": results,
568
+ "online_gates": [],
569
+ "ci": {},
570
+ "history": [],
571
+ "recorded_at": time.strftime("%Y-%m-%d"),
572
+ }
573
+ draft = out / "verification-draft.json"
574
+ if args.only and draft.exists():
575
+ previous = json.loads(draft.read_text())
576
+ fresh = {item["name"]: item for item in results}
577
+ merged = []
578
+ for item in previous.get("gates", []):
579
+ merged.append(fresh.pop(item["name"], item))
580
+ merged.extend(fresh.values())
581
+ order = {gate["name"]: index for index, gate in enumerate(table)}
582
+ payload["gates"] = sorted(merged, key=lambda item: order.get(item["name"], 999))
583
+ payload["online_gates"] = previous.get("online_gates", [])
584
+ payload["ci"] = previous.get("ci", {})
585
+ payload["history"] = previous.get("history", [])
586
+ draft.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
587
+ failed = [item for item in results if item["rc"] != 0]
588
+ print("draft: %s (%d/%d gates rc=0)" % (draft, len(results) - len(failed),
589
+ len(results)))
590
+ return 1 if failed else 0
591
+
592
+
593
+ def cmd_record_ci(args):
594
+ record = json.loads(VERIFICATION.read_text())
595
+ update = json.loads(pathlib.Path(args.file).read_text())
596
+ # `notes` explains the runs next to them; everything else is one entry
597
+ # per repository (or `publish`).
598
+ notes = update.pop("notes", None)
599
+ if notes is not None:
600
+ record["ci_notes"] = notes
601
+ record.setdefault("ci", {}).update(update)
602
+ tmp = VERIFICATION.with_suffix(".json.tmp")
603
+ tmp.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n")
604
+ tmp.replace(VERIFICATION)
605
+ print("recorded ci: %s" % ", ".join(sorted(update)))
606
+ return 0
607
+
608
+
609
+ def cmd_check(args):
610
+ record = json.loads(VERIFICATION.read_text())
611
+ if record.get("schema") != 2:
612
+ return fail("VERIFICATION.json is schema %r; check needs schema 2"
613
+ % record.get("schema"))
614
+ manifest = load_manifest()
615
+ # The tag commit carries the previous cohort's record (the record commit
616
+ # is its child). Treat that as "not written yet", not as drift: there is
617
+ # no record to compare against the manifest we are looking at.
618
+ if record.get("cohort") != manifest["cohort"]:
619
+ reason = ("record is %s, manifest is %s; the record commit lands "
620
+ "after the tag" % (record.get("cohort"), manifest["cohort"]))
621
+ if args.worktree:
622
+ return fail(reason + "; a worktree check needs a record for this "
623
+ "cohort")
624
+ if args.json:
625
+ print(json.dumps({"cohort": manifest["cohort"], "worktree": False,
626
+ "skipped": True, "reason": reason}, indent=2))
627
+ else:
628
+ print("cohort: %s; skipping" % reason)
629
+ return 0
630
+ table = gate_table(manifest)
631
+ by_name = {gate["name"]: gate for gate in table}
632
+ stale = []
633
+ for item in record["gates"]:
634
+ gate = by_name.get(item["name"])
635
+ if gate is None:
636
+ continue
637
+ audit = record.get("gate_commits", record["commits"])
638
+ got = fingerprint(gate, worktree=args.worktree,
639
+ commits=None if args.worktree else audit)
640
+ if got != item["fingerprint"]:
641
+ stale.append((gate, changed_inputs(gate, record,
642
+ worktree=args.worktree)))
643
+ rows = []
644
+ for gate in table:
645
+ item = next((x for x in record["gates"] if x["name"] == gate["name"]), None)
646
+ rows.append({
647
+ "name": gate["name"],
648
+ "repo": gate["repo"],
649
+ "state": "MISSING" if item is None else
650
+ ("STALE" if any(g is gate for g, _ in stale) else "FRESH"),
651
+ })
652
+ if args.json:
653
+ print(json.dumps({"cohort": record["cohort"], "worktree": args.worktree,
654
+ "gates": rows,
655
+ "changed": {gate["name"]: files
656
+ for gate, files in stale}}, indent=2))
657
+ else:
658
+ print("cohort: %s (%s)" % (record["cohort"],
659
+ "worktree" if args.worktree else "tagged commits"))
660
+ for row in rows:
661
+ print(" %-6s %-10s %s" % (row["state"], row["repo"], row["name"]))
662
+ for gate, files in stale:
663
+ if files:
664
+ print(" changed for %s: %s" % (gate["name"], ", ".join(files)))
665
+ return 1 if stale else 0
666
+
667
+
668
+ def main(argv=None):
669
+ parser = argparse.ArgumentParser()
670
+ sub = parser.add_subparsers(dest="cmd", required=True)
671
+ plan = sub.add_parser("plan")
672
+ plan.add_argument("--online", action="store_true")
673
+ plan.add_argument("--json", action="store_true")
674
+ plan.set_defaults(func=cmd_plan)
675
+
676
+ tag = sub.add_parser("tag")
677
+ tag.add_argument("name")
678
+ tag.add_argument("--push", action="store_true")
679
+ tag.set_defaults(func=cmd_tag)
680
+
681
+ verify = sub.add_parser("verify")
682
+ verify.add_argument("--clone", required=True)
683
+ verify.add_argument("--out")
684
+ verify.add_argument("--only")
685
+ verify.add_argument("--jobs", type=int, default=4)
686
+ verify.add_argument("--timeout", type=int, default=2400)
687
+ verify.add_argument("--reclone", action="store_true")
688
+ verify.set_defaults(func=cmd_verify)
689
+
690
+ ci = sub.add_parser("record-ci")
691
+ ci.add_argument("--file", required=True)
692
+ ci.set_defaults(func=cmd_record_ci)
693
+
694
+ check = sub.add_parser("check")
695
+ check.add_argument("--worktree", action="store_true")
696
+ check.add_argument("--json", action="store_true")
697
+ check.set_defaults(func=cmd_check)
698
+
699
+ args = parser.parse_args(argv)
700
+ return args.func(args)
701
+
702
+
703
+ if __name__ == "__main__":
704
+ sys.exit(main())