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.
- gisel_ab/__init__.py +120 -0
- gisel_ab/__main__.py +8 -0
- gisel_ab/baseline.py +145 -0
- gisel_ab/bugreport.py +113 -0
- gisel_ab/cli.py +150 -0
- gisel_ab/cohort.py +704 -0
- gisel_ab/cpp/aarch64_generated_ctor.cpp +23 -0
- gisel_ab/cpp/aarch64_query_runner.cpp +380 -0
- gisel_ab/cpp/arm_generated_ctor.cpp +18 -0
- gisel_ab/cpp/arm_query_runner.cpp +58 -0
- gisel_ab/cpp/m68k_generated_ctor.cpp +21 -0
- gisel_ab/cpp/m68k_query_runner.cpp +43 -0
- gisel_ab/cpp/mips_generated_ctor.cpp +16 -0
- gisel_ab/cpp/mips_query_runner.cpp +52 -0
- gisel_ab/cpp/ppc_generated_ctor.cpp +16 -0
- gisel_ab/cpp/ppc_query_runner.cpp +46 -0
- gisel_ab/cpp/query-recovery.h +229 -0
- gisel_ab/cpp/query_runner.cpp +419 -0
- gisel_ab/cpp/query_runner_common.h +417 -0
- gisel_ab/cpp/riscv_generated_ctor.cpp +25 -0
- gisel_ab/cpp/riscv_query_runner.cpp +379 -0
- gisel_ab/cpp/spirv_generated_ctor.cpp +24 -0
- gisel_ab/cpp/spirv_query_runner.cpp +98 -0
- gisel_ab/cpp/wasm_generated_ctor.cpp +17 -0
- gisel_ab/cpp/wasm_query_runner.cpp +52 -0
- gisel_ab/cpp/x86_generated_ctor.cpp +24 -0
- gisel_ab/data/aarch64-contract.json +102 -0
- gisel_ab/data/baseline-2026-11-12.json +1684 -0
- gisel_ab/data/closure/aarch64-D.json +156 -0
- gisel_ab/data/closure/aarch64-G.json +75 -0
- gisel_ab/data/closure/arm-D.json +50 -0
- gisel_ab/data/closure/arm-G.json +57 -0
- gisel_ab/data/closure/m68k-D.json +46 -0
- gisel_ab/data/closure/m68k-G.json +54 -0
- gisel_ab/data/closure/mips-D.json +50 -0
- gisel_ab/data/closure/mips-G.json +87 -0
- gisel_ab/data/closure/ppc-D.json +49 -0
- gisel_ab/data/closure/ppc-G.json +56 -0
- gisel_ab/data/closure/riscv-D.json +59 -0
- gisel_ab/data/closure/riscv-G.json +66 -0
- gisel_ab/data/closure/spirv-D.json +58 -0
- gisel_ab/data/closure/spirv-G.json +58 -0
- gisel_ab/data/closure/wasm-D.json +56 -0
- gisel_ab/data/closure/wasm-G.json +62 -0
- gisel_ab/data/closure/x86-D.json +60 -0
- gisel_ab/data/closure/x86-G.json +66 -0
- gisel_ab/data/domain.json +139 -0
- gisel_ab/data/mir-configs.json +444 -0
- gisel_ab/data/mir-shapes.jsonc +1168 -0
- gisel_ab/data/riscv-contract.json +102 -0
- gisel_ab/data/suite/README.md +81 -0
- gisel_ab/data/suite/aarch64/official.answers.jsonl +12559 -0
- gisel_ab/data/suite/aarch64/queries.jsonl +12559 -0
- gisel_ab/data/suite/aarch64/suite.json +43 -0
- gisel_ab/data/suite/m68k/official.answers.jsonl +35 -0
- gisel_ab/data/suite/m68k/queries.jsonl +35 -0
- gisel_ab/data/suite/m68k/suite.json +24 -0
- gisel_ab/data/suite/riscv/official.answers.jsonl +6086 -0
- gisel_ab/data/suite/riscv/queries.jsonl +6086 -0
- gisel_ab/data/suite/riscv/suite.json +41 -0
- gisel_ab/data/suite/x86/official.answers.jsonl +3014 -0
- gisel_ab/data/suite/x86/queries.jsonl +3014 -0
- gisel_ab/data/suite/x86/suite.json +33 -0
- gisel_ab/data/targets/aarch64.json +39 -0
- gisel_ab/data/targets/arm.json +39 -0
- gisel_ab/data/targets/m68k.json +39 -0
- gisel_ab/data/targets/mips.json +39 -0
- gisel_ab/data/targets/ppc.json +39 -0
- gisel_ab/data/targets/riscv.json +39 -0
- gisel_ab/data/targets/schema.json +266 -0
- gisel_ab/data/targets/spirv.json +39 -0
- gisel_ab/data/targets/wasm.json +39 -0
- gisel_ab/data/targets/x86-ctor.json +39 -0
- gisel_ab/data/targets/x86.json +39 -0
- gisel_ab/data/x86-contract.json +93 -0
- gisel_ab/doctor.py +320 -0
- gisel_ab/domain_plan.py +130 -0
- gisel_ab/evidence.py +892 -0
- gisel_ab/l0/__init__.py +0 -0
- gisel_ab/l0/attribute.py +286 -0
- gisel_ab/l0/closure.py +255 -0
- gisel_ab/l0/diff.py +388 -0
- gisel_ab/l0/g_attribution.py +93 -0
- gisel_ab/l0/inject_opcode.py +123 -0
- gisel_ab/l0/inject_predicate.py +150 -0
- gisel_ab/l0/queries.py +215 -0
- gisel_ab/l0/run.py +688 -0
- gisel_ab/l0/swap.py +257 -0
- gisel_ab/l0/u_attribution.py +192 -0
- gisel_ab/l3.py +271 -0
- gisel_ab/mir/__init__.py +0 -0
- gisel_ab/mir/config_check.py +144 -0
- gisel_ab/mir/from_query.py +717 -0
- gisel_ab/mir/libs.py +252 -0
- gisel_ab/mir/replay.py +548 -0
- gisel_ab/paths.py +151 -0
- gisel_ab/refresh_counts.py +80 -0
- gisel_ab/replay_frozen.py +154 -0
- gisel_ab/setup.py +92 -0
- gisel_ab/suite.py +390 -0
- gisel_ab/targets.py +287 -0
- gisel_ab/vertical.py +654 -0
- gisel_ab-0.9.0.dist-info/METADATA +349 -0
- gisel_ab-0.9.0.dist-info/RECORD +107 -0
- gisel_ab-0.9.0.dist-info/WHEEL +5 -0
- gisel_ab-0.9.0.dist-info/entry_points.txt +2 -0
- 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())
|