@junghanacs/entwurf 0.25.0 → 0.25.1
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.
- package/CHANGELOG.md +109 -0
- package/CONTRIBUTING.md +1 -1
- package/VERIFY.md +1 -1
- package/package.json +1 -1
- package/run.sh +5 -4
- package/scripts/check-gate-qualification.ts +2347 -7
- package/scripts/lib/qualification-acceptance.ts +146 -0
- package/scripts/lib/qualification-artifact.ts +359 -0
- package/scripts/lib/qualification-collection.ts +384 -0
- package/scripts/lib/qualification-gh.ts +231 -0
- package/scripts/lib/qualification-ledger.ts +669 -0
- package/scripts/lib/qualification-oracle-core.ts +279 -0
- package/scripts/lib/qualification-receipt.ts +561 -0
- package/scripts/lib/qualification-release.ts +271 -0
- package/scripts/mutants/qualification-ledger.json +489 -0
- package/scripts/qualification-oracle.ts +124 -0
- package/scripts/smoke-herdr-raw-install-live.sh +7 -2
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qualification-release — compose a RELEASE ledger from CI collections along a
|
|
3
|
+
* first-parent chain (#124 step 2). PURE: no network, no git, no fs. The caller hands
|
|
4
|
+
* in what it has already fetched and verified elsewhere; this function never claims its
|
|
5
|
+
* inputs came from anywhere in particular, and it produces no acceptance class.
|
|
6
|
+
*
|
|
7
|
+
* Inputs:
|
|
8
|
+
* - finalSha and firstParent (`git rev-list --first-parent finalSha`, finalSha first);
|
|
9
|
+
* - runs: one entry per CI run attempt — runId, runAttempt, headSha, and the unpacked
|
|
10
|
+
* collection files (name → bytes);
|
|
11
|
+
* - independent: the candidate recomputed by the caller from the final tree ITSELF
|
|
12
|
+
* (surface, groups, manifest claims, CORE_FILES fingerprints). Its node/node_modules
|
|
13
|
+
* part cannot be recomputed off the CI runner and is not used;
|
|
14
|
+
* - operatorDeclared: extra groups a human requires to be measured on the final SHA.
|
|
15
|
+
*
|
|
16
|
+
* Contract errors (any → RED): a run that is not CI provenance (runId/runAttempt not
|
|
17
|
+
* digits), a headSha off the first-parent chain, the same (runId, runAttempt) twice, a
|
|
18
|
+
* collection whose own runId/runAttempt/head disagree with the run, no collection at the
|
|
19
|
+
* final SHA, an incomplete final collection, or a final candidate the independent
|
|
20
|
+
* recomputation contradicts.
|
|
21
|
+
*
|
|
22
|
+
* Per SHA only the newest (runId, then runAttempt) run counts; older ones are listed as
|
|
23
|
+
* superseded. An incomplete ancestor collection is excluded from the basis with its
|
|
24
|
+
* reason. Order across runs is first-parent position, then collection index — never a
|
|
25
|
+
* clock. Release blockers on top of the local ones: every group needs a usable pass
|
|
26
|
+
* receipt somewhere in the chain (UNRUN blocks), and the runner group, every group whose
|
|
27
|
+
* newest receipt in the chain is red, and every operator-declared group must be
|
|
28
|
+
* exact-MEASURED on the final SHA. A red on an ancestor that the final SHA re-measured
|
|
29
|
+
* green stays in history and contributes no FAIL.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { type Collection, checkCollectionData, mapReader } from "./qualification-collection.ts";
|
|
33
|
+
import {
|
|
34
|
+
type ContentCandidate,
|
|
35
|
+
type CurrentCandidate,
|
|
36
|
+
composeLedger,
|
|
37
|
+
type Ledger,
|
|
38
|
+
type LoadedReceipt,
|
|
39
|
+
} from "./qualification-ledger.ts";
|
|
40
|
+
import { canonicalJson } from "./qualification-receipt.ts";
|
|
41
|
+
|
|
42
|
+
export interface CiRun {
|
|
43
|
+
runId: string;
|
|
44
|
+
runAttempt: string;
|
|
45
|
+
headSha: string;
|
|
46
|
+
files: Record<string, Buffer>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ReleaseInput {
|
|
50
|
+
finalSha: string;
|
|
51
|
+
firstParent: string[];
|
|
52
|
+
runs: CiRun[];
|
|
53
|
+
/** The final tree's content projection — no C0 environment part (see ContentCandidate). */
|
|
54
|
+
independent: ContentCandidate;
|
|
55
|
+
operatorDeclared: string[][];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RunRef {
|
|
59
|
+
runId: string;
|
|
60
|
+
runAttempt: string;
|
|
61
|
+
headSha: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ReleaseLedger extends Ledger {
|
|
65
|
+
release: {
|
|
66
|
+
finalSha: string;
|
|
67
|
+
contractErrors: string[];
|
|
68
|
+
crossCheck: string[];
|
|
69
|
+
used: RunRef[];
|
|
70
|
+
excluded: (RunRef & { reason: string })[];
|
|
71
|
+
required: string[][];
|
|
72
|
+
denominator: {
|
|
73
|
+
groups: number;
|
|
74
|
+
claims: number;
|
|
75
|
+
measuredOnFinal: { groups: number; claims: number; list: string[] };
|
|
76
|
+
inheritedCore: { gate: string; from: RunRef | null; reason: string }[];
|
|
77
|
+
flagged: string[];
|
|
78
|
+
unrun: number;
|
|
79
|
+
fail: number;
|
|
80
|
+
};
|
|
81
|
+
/** What this ledger can and cannot vouch for. */
|
|
82
|
+
provenance: string[];
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DIGITS = /^[0-9]+$/;
|
|
87
|
+
const refOf = (r: CiRun): RunRef => ({ runId: r.runId, runAttempt: r.runAttempt, headSha: r.headSha });
|
|
88
|
+
const newer = (a: CiRun, b: CiRun): boolean =>
|
|
89
|
+
BigInt(a.runId) !== BigInt(b.runId) ? BigInt(a.runId) > BigInt(b.runId) : BigInt(a.runAttempt) > BigInt(b.runAttempt);
|
|
90
|
+
|
|
91
|
+
/** Content fields a CI-claimed candidate must share with the independent recomputation. */
|
|
92
|
+
export function crossCheckCandidate(claimed: CurrentCandidate, independent: ContentCandidate): string[] {
|
|
93
|
+
const out: string[] = [];
|
|
94
|
+
if (claimed.head !== independent.head) out.push(`head: CI ${claimed.head} vs tree ${independent.head}`);
|
|
95
|
+
if (claimed.workSurfaceSha256 !== independent.workSurfaceSha256) out.push("workSurface");
|
|
96
|
+
if (canonicalJson(claimed.manifestClaims) !== canonicalJson(independent.manifestClaims)) out.push("manifestClaims");
|
|
97
|
+
if (canonicalJson(claimed.groups) !== canonicalJson(independent.groups)) out.push("groups");
|
|
98
|
+
if (!claimed.coreFiles || !independent.coreFiles) out.push("coreFiles: missing on one side");
|
|
99
|
+
else if (canonicalJson(claimed.coreFiles) !== canonicalJson(independent.coreFiles)) out.push("coreFiles");
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function composeRelease(input: ReleaseInput): ReleaseLedger {
|
|
104
|
+
const contractErrors: string[] = [];
|
|
105
|
+
const excluded: ReleaseLedger["release"]["excluded"] = [];
|
|
106
|
+
const chainPos = new Map(input.firstParent.map((sha, i) => [sha, i]));
|
|
107
|
+
if (input.firstParent[0] !== input.finalSha) contractErrors.push("firstParent must start with finalSha");
|
|
108
|
+
|
|
109
|
+
// 1. Admission: provenance shape, chain membership, uniqueness, collection integrity.
|
|
110
|
+
const seenAttempt = new Set<string>();
|
|
111
|
+
const admitted: { run: CiRun; collection: Collection; complete: boolean; errors: string[] }[] = [];
|
|
112
|
+
for (const run of input.runs) {
|
|
113
|
+
const tag = `run ${run.runId}#${run.runAttempt}@${run.headSha.slice(0, 12)}`;
|
|
114
|
+
if (!DIGITS.test(run.runId) || !DIGITS.test(run.runAttempt)) {
|
|
115
|
+
contractErrors.push(`${tag}: runId/runAttempt are not CI provenance`);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const key = `${run.runId}#${run.runAttempt}`;
|
|
119
|
+
if (seenAttempt.has(key)) {
|
|
120
|
+
contractErrors.push(`${tag}: the same run attempt is supplied twice`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
seenAttempt.add(key);
|
|
124
|
+
if (!chainPos.has(run.headSha)) {
|
|
125
|
+
contractErrors.push(`${tag}: headSha is not on the first-parent chain of ${input.finalSha.slice(0, 12)}`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const { errors, collection } = checkCollectionData(mapReader(run.files), Object.keys(run.files).sort());
|
|
129
|
+
if (!collection) {
|
|
130
|
+
contractErrors.push(`${tag}: ${errors.join("; ")}`);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (collection.runId !== run.runId || collection.runAttempt !== run.runAttempt) {
|
|
134
|
+
contractErrors.push(`${tag}: the collection records run ${collection.runId}#${collection.runAttempt}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (collection.candidate.head !== run.headSha) {
|
|
138
|
+
contractErrors.push(`${tag}: the collection measured head ${collection.candidate.head}`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
admitted.push({ run, collection, complete: errors.length === 0, errors });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 2. One run per SHA: the newest attempt wins; the rest are superseded, never mixed in.
|
|
145
|
+
const perSha = new Map<string, (typeof admitted)[number]>();
|
|
146
|
+
for (const a of admitted) {
|
|
147
|
+
const cur = perSha.get(a.run.headSha);
|
|
148
|
+
if (!cur || newer(a.run, cur.run)) {
|
|
149
|
+
if (cur) excluded.push({ ...refOf(cur.run), reason: `superseded by run ${a.run.runId}#${a.run.runAttempt}` });
|
|
150
|
+
perSha.set(a.run.headSha, a);
|
|
151
|
+
} else {
|
|
152
|
+
excluded.push({ ...refOf(a.run), reason: `superseded by run ${cur.run.runId}#${cur.run.runAttempt}` });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const finalEntry = perSha.get(input.finalSha);
|
|
156
|
+
if (!finalEntry) contractErrors.push(`no CI collection at the final SHA ${input.finalSha.slice(0, 12)}`);
|
|
157
|
+
else if (!finalEntry.complete)
|
|
158
|
+
contractErrors.push(`final-SHA collection incomplete: ${finalEntry.errors.join("; ")}`);
|
|
159
|
+
const used: (typeof admitted)[number][] = [];
|
|
160
|
+
for (const [sha, a] of perSha) {
|
|
161
|
+
if (sha !== input.finalSha && !a.complete) {
|
|
162
|
+
excluded.push({ ...refOf(a.run), reason: `incomplete ancestor collection, not a basis: ${a.errors.join("; ")}` });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
used.push(a);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 3. The final candidate: content from the independent recomputation, C0 environment
|
|
169
|
+
// part (node, node_modules) as the final run witnessed it.
|
|
170
|
+
const crossCheck = finalEntry ? crossCheckCandidate(finalEntry.collection.candidate, input.independent) : [];
|
|
171
|
+
const current: CurrentCandidate = {
|
|
172
|
+
...input.independent,
|
|
173
|
+
head: input.finalSha,
|
|
174
|
+
// porcelainClean stays the INDEPENDENT candidate's own: a clean final tree is the
|
|
175
|
+
// oracle's to produce and prove, never this function's to assume.
|
|
176
|
+
coreSha256: finalEntry ? finalEntry.collection.candidate.coreSha256 : "unwitnessed",
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// 4. Receipts in chain order (older first-parent position first), collection index next.
|
|
180
|
+
const maxPos = input.firstParent.length;
|
|
181
|
+
const loaded: LoadedReceipt[] = [];
|
|
182
|
+
const receiptRun = new Map<string, RunRef>();
|
|
183
|
+
const finalSources = new Set<string>();
|
|
184
|
+
for (const a of used) {
|
|
185
|
+
const base = (maxPos - (chainPos.get(a.run.headSha) ?? maxPos)) * 1_000_000;
|
|
186
|
+
a.collection.attempted.forEach((att, i) => {
|
|
187
|
+
if (att.outcome !== "receipt-written" || !att.file) return;
|
|
188
|
+
const bytes = a.run.files[att.file];
|
|
189
|
+
if (!bytes) return;
|
|
190
|
+
if (a.run.headSha === input.finalSha) finalSources.add(`run ${a.run.runId}#${a.run.runAttempt}/${att.file}`);
|
|
191
|
+
loaded.push({
|
|
192
|
+
source: `run ${a.run.runId}#${a.run.runAttempt}/${att.file}`,
|
|
193
|
+
text: bytes.toString("utf8"),
|
|
194
|
+
seq: base + i,
|
|
195
|
+
});
|
|
196
|
+
try {
|
|
197
|
+
receiptRun.set(JSON.parse(bytes.toString("utf8")).receiptSha256, refOf(a.run));
|
|
198
|
+
} catch {
|
|
199
|
+
// unparseable: composeLedger reports it as INVALID
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 5. The required set on the final SHA: runner, every group whose newest receipt among
|
|
205
|
+
// the ANCESTOR runs is red (the section that broke — computed before the final run,
|
|
206
|
+
// so re-measuring it green does not erase the requirement), operator-declared.
|
|
207
|
+
const newestPassed = new Map<string, boolean>();
|
|
208
|
+
for (const l of [...loaded].filter((x) => !finalSources.has(x.source)).sort((x, y) => (x.seq ?? 0) - (y.seq ?? 0))) {
|
|
209
|
+
try {
|
|
210
|
+
const r = JSON.parse(l.text);
|
|
211
|
+
newestPassed.set(JSON.stringify(r.group.gate), r.result.passed === true);
|
|
212
|
+
} catch {
|
|
213
|
+
// INVALID, reported by composeLedger
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const requiredKeys = new Set<string>([JSON.stringify(input.independent.coreGroupGate)]);
|
|
217
|
+
for (const [k, passed] of newestPassed) if (!passed) requiredKeys.add(k);
|
|
218
|
+
for (const argv of input.operatorDeclared) requiredKeys.add(JSON.stringify(argv));
|
|
219
|
+
const required = [...requiredKeys].map((k) => JSON.parse(k) as string[]);
|
|
220
|
+
|
|
221
|
+
const ledger = composeLedger(loaded, current, undefined, { mode: "release", required });
|
|
222
|
+
const red = contractErrors.length > 0 || crossCheck.length > 0;
|
|
223
|
+
const verdict = red ? "RED" : ledger.verdict;
|
|
224
|
+
const summary = [
|
|
225
|
+
...(red ? [`[qualification-release] RED on ${input.finalSha.slice(0, 12)}`] : []),
|
|
226
|
+
...contractErrors.map((e) => ` CONTRACT ${e}`),
|
|
227
|
+
...crossCheck.map((e) => ` CROSS-CHECK ${e}: the CI-claimed candidate contradicts the final tree`),
|
|
228
|
+
...excluded.map((e) => ` EXCLUDED run ${e.runId}#${e.runAttempt}@${e.headSha.slice(0, 12)}: ${e.reason}`),
|
|
229
|
+
...ledger.summary,
|
|
230
|
+
];
|
|
231
|
+
const measured = ledger.groups.filter((g) => g.state === "MEASURED" && g.provenance === "exact");
|
|
232
|
+
return {
|
|
233
|
+
...ledger,
|
|
234
|
+
verdict,
|
|
235
|
+
summary,
|
|
236
|
+
release: {
|
|
237
|
+
finalSha: input.finalSha,
|
|
238
|
+
contractErrors,
|
|
239
|
+
crossCheck,
|
|
240
|
+
used: used.map((a) => refOf(a.run)),
|
|
241
|
+
excluded,
|
|
242
|
+
required,
|
|
243
|
+
denominator: {
|
|
244
|
+
groups: ledger.counts.groups.total,
|
|
245
|
+
claims: ledger.counts.claims.total,
|
|
246
|
+
measuredOnFinal: {
|
|
247
|
+
groups: measured.length,
|
|
248
|
+
claims: measured.reduce((n, g) => n + g.claims.length, 0),
|
|
249
|
+
list: measured.map((g) => g.gate.join(" ")),
|
|
250
|
+
},
|
|
251
|
+
inheritedCore: ledger.groups
|
|
252
|
+
.filter((g) => g.state === "INHERITED-UNVERIFIED" && g.inheritKind === "c-core")
|
|
253
|
+
.map((g) => ({
|
|
254
|
+
gate: g.gate.join(" "),
|
|
255
|
+
from: (g.decidedBy && receiptRun.get(g.decidedBy)) || null,
|
|
256
|
+
reason: g.reasons[0],
|
|
257
|
+
})),
|
|
258
|
+
flagged: ledger.groups
|
|
259
|
+
.filter((g) => g.flags.length > 0)
|
|
260
|
+
.map((g) => `${g.gate.join(" ")}: ${g.flags.join("; ")}`),
|
|
261
|
+
unrun: ledger.counts.groups.UNRUN,
|
|
262
|
+
fail: ledger.counts.groups.FAIL,
|
|
263
|
+
},
|
|
264
|
+
provenance: [
|
|
265
|
+
"inputs are whatever the caller supplied; this function does not fetch or authenticate them",
|
|
266
|
+
"node/node_modules identity is the final run's own witness, not recomputed here",
|
|
267
|
+
"no acceptance class is produced: RISK-PENDING awaits a human decision",
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
}
|