@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
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* TWO ENTRYPOINTS, ONE FILE. `--manifests-only` (shipped as `run.sh
|
|
5
5
|
* check-gate-manifests`) stops after the HEAD — the runner self-test plus the real
|
|
6
|
-
* manifests' validation and lane inventory — having executed ZERO mutants
|
|
7
|
-
* `scripts/mutants/` and having made NO snapshot of this repo. The
|
|
6
|
+
* manifests' validation and lane inventory — having executed ZERO committed mutants
|
|
7
|
+
* from `scripts/mutants/` and having made NO snapshot of this repo. (The self-test
|
|
8
|
+
* does execute its own SYNTHETIC fixture mutants, in fixture repos under tmp.) The default
|
|
8
9
|
* entrypoint is unchanged: it runs the same head first and then the body. The head
|
|
9
10
|
* is separable because of what it has actually caught: across 549 CI runs the
|
|
10
11
|
* qualification step went red five times, and three of those died in the head in
|
|
11
12
|
* 4-5 seconds, before a single mutant gate was invoked (#99 B-3). The head costs
|
|
12
|
-
* ~
|
|
13
|
+
* ~20s (measured 2026-09-29 on oracle, after the #124 group-receipt and ledger
|
|
14
|
+
* fixture cells; ~12s before them), so it belongs in the deterministic floor every push already pays for; the
|
|
13
15
|
* body's contract — which mutants run, how they are classified, what it prints —
|
|
14
16
|
* is untouched by the split.
|
|
15
17
|
*
|
|
@@ -43,6 +45,7 @@
|
|
|
43
45
|
|
|
44
46
|
import assert from "node:assert/strict";
|
|
45
47
|
import { spawnSync } from "node:child_process";
|
|
48
|
+
import { createHash } from "node:crypto";
|
|
46
49
|
import * as fs from "node:fs";
|
|
47
50
|
import * as os from "node:os";
|
|
48
51
|
import * as path from "node:path";
|
|
@@ -67,13 +70,73 @@ import {
|
|
|
67
70
|
validateManifest,
|
|
68
71
|
validateManifestSet,
|
|
69
72
|
} from "./lib/mutation-qualify.ts";
|
|
73
|
+
import { ACCEPTANCE_SCHEMA, checkAcceptanceRecord, RECORD_CONSISTENT } from "./lib/qualification-acceptance.ts";
|
|
74
|
+
import {
|
|
75
|
+
type ApiArtifact,
|
|
76
|
+
type ArtifactInput,
|
|
77
|
+
admitArtifact,
|
|
78
|
+
artifactName,
|
|
79
|
+
selectArtifact,
|
|
80
|
+
} from "./lib/qualification-artifact.ts";
|
|
81
|
+
import {
|
|
82
|
+
allGroupArgv,
|
|
83
|
+
checkCollection,
|
|
84
|
+
checkCollectionData,
|
|
85
|
+
checkRequestedGroups,
|
|
86
|
+
collectGroups,
|
|
87
|
+
mapReader,
|
|
88
|
+
} from "./lib/qualification-collection.ts";
|
|
89
|
+
import {
|
|
90
|
+
ancestorRunEvidence,
|
|
91
|
+
COMPOSITE_JOB,
|
|
92
|
+
finalRunEvidence,
|
|
93
|
+
type GhApi,
|
|
94
|
+
REQUIRED_JOBS,
|
|
95
|
+
} from "./lib/qualification-gh.ts";
|
|
96
|
+
import {
|
|
97
|
+
composeLedger,
|
|
98
|
+
contentCandidate,
|
|
99
|
+
currentCandidate,
|
|
100
|
+
loadReceipt,
|
|
101
|
+
parseExpectedList,
|
|
102
|
+
} from "./lib/qualification-ledger.ts";
|
|
103
|
+
import {
|
|
104
|
+
checkDeclared,
|
|
105
|
+
decisionDigest,
|
|
106
|
+
type GitRunner,
|
|
107
|
+
parseOracleArgs,
|
|
108
|
+
runOracle,
|
|
109
|
+
selfCheck,
|
|
110
|
+
} from "./lib/qualification-oracle-core.ts";
|
|
111
|
+
import {
|
|
112
|
+
CORE_FILES,
|
|
113
|
+
canonicalJson,
|
|
114
|
+
checkReceiptPath,
|
|
115
|
+
fileFingerprints,
|
|
116
|
+
parseQualificationArgs,
|
|
117
|
+
runGroupReceipt,
|
|
118
|
+
sealReceipt,
|
|
119
|
+
sha256,
|
|
120
|
+
writeReceiptExclusive,
|
|
121
|
+
} from "./lib/qualification-receipt.ts";
|
|
122
|
+
import { type CiRun, composeRelease, type ReleaseInput } from "./lib/qualification-release.ts";
|
|
70
123
|
import { reclaimOnExit } from "./lib/reclaim-on-exit.ts";
|
|
124
|
+
import { launch } from "./qualification-oracle.ts";
|
|
71
125
|
|
|
72
126
|
const REPO_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
73
127
|
const MUTANTS_DIR = path.join(REPO_DIR, "scripts", "mutants");
|
|
74
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Refused before anything runs: an unknown/repeated flag, or a malformed `--group`
|
|
131
|
+
* (#124 P1 — one exact-argv group, PARTIAL receipt; see qualification-receipt.ts).
|
|
132
|
+
*/
|
|
133
|
+
const ARGS = parseQualificationArgs(process.argv.slice(2));
|
|
134
|
+
if (ARGS.mode === "group") checkReceiptPath(ARGS.receiptPath, REPO_DIR);
|
|
135
|
+
if (ARGS.mode === "compose" && ARGS.ledgerOut !== undefined) checkReceiptPath(ARGS.ledgerOut, REPO_DIR);
|
|
136
|
+
if (ARGS.mode === "groups") checkReceiptPath(ARGS.collectDir, REPO_DIR);
|
|
137
|
+
|
|
75
138
|
/** Head-only mode: validate, count, and stop — no mutant is executed, no repo snapshot is made. */
|
|
76
|
-
const MANIFESTS_ONLY =
|
|
139
|
+
const MANIFESTS_ONLY = ARGS.mode === "manifests-only";
|
|
77
140
|
const SURFACE = MANIFESTS_ONLY ? "check-gate-manifests" : "gate-qualification";
|
|
78
141
|
|
|
79
142
|
let passed = 0;
|
|
@@ -168,11 +231,34 @@ function checkVitestAttribution(): void {
|
|
|
168
231
|
}
|
|
169
232
|
|
|
170
233
|
checkVitestAttribution();
|
|
171
|
-
if (
|
|
234
|
+
if (ARGS.mode === "attribution-self-test") {
|
|
172
235
|
console.log(`[gate-qualification] attribution self-test: ${passed} checks passed`);
|
|
173
236
|
process.exit(0);
|
|
174
237
|
}
|
|
175
238
|
|
|
239
|
+
// ═══ Phase 0c — every qualification source is part of C0 (#124) ════════════
|
|
240
|
+
//
|
|
241
|
+
// A qualification source whose bytes can change without moving the receipt core seal,
|
|
242
|
+
// the c-core decision or the CI core witness is an unseen judge. CORE_FILES is a
|
|
243
|
+
// hand-written list, so membership is checked against the work surface, first.
|
|
244
|
+
{
|
|
245
|
+
const listed = spawnSync("git", ["-C", REPO_DIR, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
246
|
+
encoding: "utf8",
|
|
247
|
+
});
|
|
248
|
+
assert.equal(listed.status, 0, "git ls-files failed");
|
|
249
|
+
const sources = listed.stdout
|
|
250
|
+
.split("\0")
|
|
251
|
+
.filter((f) => /^scripts\/(lib\/)?qualification-[^/]+\.ts$/.test(f))
|
|
252
|
+
.sort();
|
|
253
|
+
const missing = sources.filter((f) => !CORE_FILES.includes(f));
|
|
254
|
+
ok(
|
|
255
|
+
`[QK:CORE-FILES-COVER-QUALIFICATION] C0: every qualification source the regex finds on the work surface is in CORE_FILES (${sources.length} found${missing.length > 0 ? `; missing ${missing.join(", ")}` : ""})`,
|
|
256
|
+
sources.includes("scripts/lib/qualification-acceptance.ts") &&
|
|
257
|
+
sources.includes("scripts/qualification-oracle.ts") &&
|
|
258
|
+
missing.length === 0,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
176
262
|
// ═══ Phase 1a — the pure classifier, exhausted as a truth table ═════════════
|
|
177
263
|
|
|
178
264
|
{
|
|
@@ -793,6 +879,2177 @@ const quiet = (): void => {};
|
|
|
793
879
|
}
|
|
794
880
|
}
|
|
795
881
|
|
|
882
|
+
// ═══ Phase 1c-g — the PARTIAL group receipt over the fixture (#124 P1) ═════
|
|
883
|
+
//
|
|
884
|
+
// `--group` is a mixed CLI/process surface (argv, snapshot, gate children, a file
|
|
885
|
+
// written outside the repo), so its proof rides this self-test next to the pipeline
|
|
886
|
+
// cells it reuses instead of a separate Vitest lane. Every cell below runs in the
|
|
887
|
+
// head, so `check-gate-manifests` pins it on every floor run.
|
|
888
|
+
|
|
889
|
+
{
|
|
890
|
+
const refuse = (argv: string[], naming: string): void =>
|
|
891
|
+
throws(
|
|
892
|
+
`group args: ${JSON.stringify(argv)} is refused before anything runs`,
|
|
893
|
+
() => parseQualificationArgs(argv),
|
|
894
|
+
naming,
|
|
895
|
+
);
|
|
896
|
+
const g = '["bash","gates/kill.sh"]';
|
|
897
|
+
ok("group args: no argument is the unchanged full body", parseQualificationArgs([]).mode === "full");
|
|
898
|
+
ok(
|
|
899
|
+
"group args: --manifests-only alone is the head",
|
|
900
|
+
parseQualificationArgs(["--manifests-only"]).mode === "manifests-only",
|
|
901
|
+
);
|
|
902
|
+
const parsedGroup = parseQualificationArgs(["--group", g, "--receipt", "/x/r.json"]);
|
|
903
|
+
ok(
|
|
904
|
+
"group args: --group/--receipt parse to the exact argv and path",
|
|
905
|
+
parsedGroup.mode === "group" &&
|
|
906
|
+
JSON.stringify(parsedGroup.group) === g.replace(/ /g, "") &&
|
|
907
|
+
parsedGroup.receiptPath === "/x/r.json",
|
|
908
|
+
);
|
|
909
|
+
refuse(["--bogus"], "unknown argument");
|
|
910
|
+
refuse(["--manifests-only", "--manifests-only"], "repeated flag");
|
|
911
|
+
refuse(["--group", g], "--group needs --receipt");
|
|
912
|
+
refuse(["--receipt", "/x/r.json"], "--receipt needs --group");
|
|
913
|
+
refuse(["--receipt"], "needs a value");
|
|
914
|
+
refuse(["--group", g, "--receipt", "/x/r.json", "--manifests-only"], "cannot combine");
|
|
915
|
+
refuse(["--manifests-only", "--attribution-self-test"], "exclusive");
|
|
916
|
+
refuse(["--group", "check-x", "--receipt", "/x/r.json"], "JSON array");
|
|
917
|
+
refuse(["--group", "[]", "--receipt", "/x/r.json"], "non-empty JSON array");
|
|
918
|
+
refuse(["--compose"], "at least one receipt file");
|
|
919
|
+
refuse(["--expect", "/x/e.txt"], "need --compose");
|
|
920
|
+
refuse(["--compose", "/x/r.json", "--manifests-only"], "--compose cannot combine");
|
|
921
|
+
refuse(["--compose", "/x/r.json", "--group", g, "--receipt", "/x/q.json"], "--compose cannot combine");
|
|
922
|
+
const parsedCompose = parseQualificationArgs(["--compose", "/a.json", "/b.json", "--expect", "/e.txt"]);
|
|
923
|
+
ok(
|
|
924
|
+
"compose args: --compose takes the files up to the next flag, --expect its list",
|
|
925
|
+
parsedCompose.mode === "compose" &&
|
|
926
|
+
JSON.stringify(parsedCompose.receipts) === '["/a.json","/b.json"]' &&
|
|
927
|
+
parsedCompose.expectPath === "/e.txt",
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
const tmpRoot = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-receipt-selftest-")));
|
|
931
|
+
const { dir: origin, externalTarget } = buildFixtureOrigin();
|
|
932
|
+
try {
|
|
933
|
+
const out = (name: string): string => path.join(tmpRoot, name);
|
|
934
|
+
fs.writeFileSync(out("exists.json"), "keep\n");
|
|
935
|
+
throws("receipt path: relative is refused", () => checkReceiptPath("r.json", origin), "must be absolute");
|
|
936
|
+
throws(
|
|
937
|
+
"receipt path: an existing file is refused, never overwritten",
|
|
938
|
+
() => checkReceiptPath(out("exists.json"), origin),
|
|
939
|
+
"refusing to overwrite",
|
|
940
|
+
);
|
|
941
|
+
throws(
|
|
942
|
+
"receipt path: inside the repo is refused (it would move the seal it records)",
|
|
943
|
+
() => checkReceiptPath(path.join(origin, "r.json"), origin),
|
|
944
|
+
"outside the repo",
|
|
945
|
+
);
|
|
946
|
+
throws(
|
|
947
|
+
"receipt path: a missing parent is refused",
|
|
948
|
+
() => checkReceiptPath(out("nope/r.json"), origin),
|
|
949
|
+
"parent directory does not exist",
|
|
950
|
+
);
|
|
951
|
+
|
|
952
|
+
writeReceiptExclusive(out("w.json"), { a: 1 });
|
|
953
|
+
ok(
|
|
954
|
+
"receipt write: bytes land whole, mode 600, no temp left",
|
|
955
|
+
JSON.parse(fs.readFileSync(out("w.json"), "utf8")).a === 1 &&
|
|
956
|
+
(fs.statSync(out("w.json")).mode & 0o777) === 0o600 &&
|
|
957
|
+
fs.readdirSync(tmpRoot).every((f) => !f.includes(".tmp-")),
|
|
958
|
+
);
|
|
959
|
+
throws(
|
|
960
|
+
"receipt write: a second write to the same path fails",
|
|
961
|
+
() => writeReceiptExclusive(out("w.json"), { a: 2 }),
|
|
962
|
+
"EEXIST",
|
|
963
|
+
);
|
|
964
|
+
ok(
|
|
965
|
+
"receipt write: the first receipt survives the refused second write, no temp left",
|
|
966
|
+
JSON.parse(fs.readFileSync(out("w.json"), "utf8")).a === 1 &&
|
|
967
|
+
fs.readdirSync(tmpRoot).every((f) => !f.includes(".tmp-")),
|
|
968
|
+
);
|
|
969
|
+
throws(
|
|
970
|
+
"receipt write: a body that cannot serialize writes nothing",
|
|
971
|
+
() => writeReceiptExclusive(out("bad.json"), { n: 1n }),
|
|
972
|
+
"BigInt",
|
|
973
|
+
);
|
|
974
|
+
ok(
|
|
975
|
+
"receipt write: the failed write left neither the receipt nor its temp",
|
|
976
|
+
!fs.existsSync(out("bad.json")) && fs.readdirSync(tmpRoot).every((f) => !f.includes(".tmp-")),
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
// An intermediate DIRECTORY symlink out of the repo: lexically `linkdir/…` is inside,
|
|
980
|
+
// but its bytes live next to externalTarget. Must be refused by realpath, not read.
|
|
981
|
+
fs.symlinkSync(path.dirname(externalTarget), path.join(origin, "linkdir"));
|
|
982
|
+
const fp = fileFingerprints(origin, [
|
|
983
|
+
"../escape.txt",
|
|
984
|
+
"/etc/hostname",
|
|
985
|
+
"linkdir/external-target.txt",
|
|
986
|
+
"linked.txt",
|
|
987
|
+
"subject.txt",
|
|
988
|
+
"absent.txt",
|
|
989
|
+
]);
|
|
990
|
+
ok(
|
|
991
|
+
"fingerprints: a path outside the repo is marked, never read",
|
|
992
|
+
fp["../escape.txt"] === "outside-repo" && fp["/etc/hostname"] === "outside-repo",
|
|
993
|
+
);
|
|
994
|
+
ok(
|
|
995
|
+
"[QK:RECEIPT-PARENT-REALPATH-CONTAINED] fingerprints: a file reached through an intermediate directory symlink out of the repo is `outside-repo`, not hashed",
|
|
996
|
+
fp["linkdir/external-target.txt"] === "outside-repo",
|
|
997
|
+
);
|
|
998
|
+
fs.rmSync(path.join(origin, "linkdir"));
|
|
999
|
+
ok(
|
|
1000
|
+
"fingerprints: a symlink is recorded by target, not followed; absent is `missing`",
|
|
1001
|
+
fp["linked.txt"] === `link:${externalTarget}` &&
|
|
1002
|
+
fp["absent.txt"] === "missing" &&
|
|
1003
|
+
/^[0-9a-f]{64}$/.test(fp["subject.txt"]),
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
// The self-test's own gate scripts, as the committed set the group is chosen from.
|
|
1007
|
+
const selectedFx = [
|
|
1008
|
+
fixtureSpec({}),
|
|
1009
|
+
fixtureSpec({
|
|
1010
|
+
claim: "SELFTEST-SURVIVE",
|
|
1011
|
+
gate: ["bash", "gates/survive.sh"],
|
|
1012
|
+
signature: "[QK:SELFTEST-SURVIVE]",
|
|
1013
|
+
}),
|
|
1014
|
+
];
|
|
1015
|
+
const runFx = (gate: string[], name: string) =>
|
|
1016
|
+
runGroupReceipt({
|
|
1017
|
+
repoDir: origin,
|
|
1018
|
+
selected: selectedFx,
|
|
1019
|
+
gate,
|
|
1020
|
+
receiptPath: out(name),
|
|
1021
|
+
log: quiet,
|
|
1022
|
+
logError: quiet,
|
|
1023
|
+
tmpRoot,
|
|
1024
|
+
});
|
|
1025
|
+
const snapshotsLeft = (): string[] => fs.readdirSync(tmpRoot).filter((f) => f.startsWith("entwurf-qualify-"));
|
|
1026
|
+
const readReceipt = (name: string) => JSON.parse(fs.readFileSync(out(name), "utf8"));
|
|
1027
|
+
|
|
1028
|
+
const green = await runFx(["bash", "gates/kill.sh"], "green.json");
|
|
1029
|
+
const gr = readReceipt("green.json");
|
|
1030
|
+
ok(
|
|
1031
|
+
"group run: a killing group writes a green PARTIAL receipt of exactly its claims",
|
|
1032
|
+
green.passed &&
|
|
1033
|
+
gr.result.passed === true &&
|
|
1034
|
+
gr.label.startsWith("PARTIAL") &&
|
|
1035
|
+
JSON.stringify(gr.group.claims) === '["SELFTEST-KILL"]' &&
|
|
1036
|
+
gr.result.mutants[0].verdict === "KILLED" &&
|
|
1037
|
+
gr.manifestSet.claimCount === 2 &&
|
|
1038
|
+
gr.seal.stable === true &&
|
|
1039
|
+
gr.receiptSha256 === green.receiptSha256,
|
|
1040
|
+
);
|
|
1041
|
+
ok("group run: the snapshot is reclaimed", snapshotsLeft().length === 0);
|
|
1042
|
+
|
|
1043
|
+
const red = await runFx(["bash", "gates/survive.sh"], "red.json");
|
|
1044
|
+
const rr = readReceipt("red.json");
|
|
1045
|
+
ok(
|
|
1046
|
+
"group run: a SURVIVED mutant is still written — red receipt, passed false",
|
|
1047
|
+
red.passed === false && rr.result.passed === false && rr.result.mutants[0].verdict === "SURVIVED",
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
let refused = "";
|
|
1051
|
+
try {
|
|
1052
|
+
await runFx(["bash", "gates/nope.sh"], "none.json");
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
refused = err instanceof Error ? err.message : String(err);
|
|
1055
|
+
}
|
|
1056
|
+
ok(
|
|
1057
|
+
"group run: an argv naming no committed group refuses with no receipt and no snapshot",
|
|
1058
|
+
refused.includes("names no exact gate argv") && !fs.existsSync(out("none.json")) && snapshotsLeft().length === 0,
|
|
1059
|
+
);
|
|
1060
|
+
|
|
1061
|
+
// SEAL MOVED: a gate that kills its mutant for the claimed reason but ALSO writes
|
|
1062
|
+
// the ORIGIN (by absolute path — the fence does not stop it). The kill is real;
|
|
1063
|
+
// the receipt must still be red because the candidate it describes moved.
|
|
1064
|
+
fs.writeFileSync(
|
|
1065
|
+
path.join(origin, "gates/mover.sh"),
|
|
1066
|
+
`#!/usr/bin/env bash\nif grep -q defect-line subject.txt; then echo moved >> ${JSON.stringify(path.join(origin, "subject.txt"))}; echo "FAIL: planted defect detected [QK:SELFTEST-MOVER]" >&2; exit 1; fi\nexit 0\n`,
|
|
1067
|
+
);
|
|
1068
|
+
const moverSpec = fixtureSpec({
|
|
1069
|
+
claim: "SELFTEST-MOVER",
|
|
1070
|
+
gate: ["bash", "gates/mover.sh"],
|
|
1071
|
+
signature: "[QK:SELFTEST-MOVER]",
|
|
1072
|
+
signatureSource: "gates/mover.sh",
|
|
1073
|
+
});
|
|
1074
|
+
const moved = await runGroupReceipt({
|
|
1075
|
+
repoDir: origin,
|
|
1076
|
+
selected: [moverSpec],
|
|
1077
|
+
gate: moverSpec.gate,
|
|
1078
|
+
receiptPath: out("moved.json"),
|
|
1079
|
+
log: quiet,
|
|
1080
|
+
logError: quiet,
|
|
1081
|
+
tmpRoot,
|
|
1082
|
+
});
|
|
1083
|
+
const mr = readReceipt("moved.json");
|
|
1084
|
+
ok(
|
|
1085
|
+
"[QK:RECEIPT-SEAL-MOVED-IS-RED] group run: an origin write during the run returns cleanly with seal.stable false and passed false",
|
|
1086
|
+
moved.passed === false &&
|
|
1087
|
+
mr.result.mutants[0].verdict === "KILLED" &&
|
|
1088
|
+
mr.seal.stable === false &&
|
|
1089
|
+
mr.seal.workSurfaceAfterSha256 !== mr.candidate.workSurfaceSha256 &&
|
|
1090
|
+
JSON.stringify(mr.seal.declaredInputsChanged) === '["subject.txt"]' &&
|
|
1091
|
+
snapshotsLeft().length === 0,
|
|
1092
|
+
);
|
|
1093
|
+
} finally {
|
|
1094
|
+
fs.rmSync(origin, { recursive: true, force: true });
|
|
1095
|
+
fs.rmSync(path.dirname(externalTarget), { recursive: true, force: true });
|
|
1096
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
const refuseArgs = (argv: string[], naming: string): void =>
|
|
1101
|
+
throws(`args: ${JSON.stringify(argv)} is refused before anything runs`, () => parseQualificationArgs(argv), naming);
|
|
1102
|
+
|
|
1103
|
+
// ═══ Phase 1c-m — several groups, one collection (#124 step 1) ════════════
|
|
1104
|
+
//
|
|
1105
|
+
// The production collectGroups over a fixture origin: a red group in the middle does not
|
|
1106
|
+
// stop the tail; a runner crash and a moved origin seal DO stop it, leaving the rest
|
|
1107
|
+
// `not-attempted`; and checkCollection names a missing, altered or undeclared file.
|
|
1108
|
+
|
|
1109
|
+
{
|
|
1110
|
+
const tmpRoot = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-collect-selftest-")));
|
|
1111
|
+
const { dir: origin, externalTarget } = buildFixtureOrigin();
|
|
1112
|
+
try {
|
|
1113
|
+
fs.writeFileSync(path.join(origin, "tail.txt"), "tail-ok\n");
|
|
1114
|
+
fs.writeFileSync(
|
|
1115
|
+
path.join(origin, "gates/tail.sh"),
|
|
1116
|
+
'#!/usr/bin/env bash\nif grep -q tail-bad tail.txt; then echo "FAIL: tail defect [QK:SELFTEST-TAIL]" >&2; exit 1; fi\nexit 0\n',
|
|
1117
|
+
{ mode: 0o755 },
|
|
1118
|
+
);
|
|
1119
|
+
fs.writeFileSync(
|
|
1120
|
+
path.join(origin, "gates/mover.sh"),
|
|
1121
|
+
`#!/usr/bin/env bash\nif grep -q defect-line subject.txt; then echo moved >> ${JSON.stringify(path.join(origin, "subject.txt"))}; echo "FAIL: planted defect detected [QK:SELFTEST-MOVER]" >&2; exit 1; fi\nexit 0\n`,
|
|
1122
|
+
{ mode: 0o755 },
|
|
1123
|
+
);
|
|
1124
|
+
const sA = fixtureSpec({});
|
|
1125
|
+
const sB = fixtureSpec({
|
|
1126
|
+
claim: "SELFTEST-SURVIVE",
|
|
1127
|
+
gate: ["bash", "gates/survive.sh"],
|
|
1128
|
+
signature: "[QK:SELFTEST-SURVIVE]",
|
|
1129
|
+
});
|
|
1130
|
+
const sC = fixtureSpec({
|
|
1131
|
+
claim: "SELFTEST-TAIL",
|
|
1132
|
+
subject: "tail.txt",
|
|
1133
|
+
find: ["tail-ok"],
|
|
1134
|
+
replace: ["tail-bad"],
|
|
1135
|
+
gate: ["bash", "gates/tail.sh"],
|
|
1136
|
+
signature: "[QK:SELFTEST-TAIL]",
|
|
1137
|
+
signatureSource: "gates/tail.sh",
|
|
1138
|
+
});
|
|
1139
|
+
// A tracked SYMLINK subject: the runtime symlink-escape guard throws inside the body.
|
|
1140
|
+
const sCrash = fixtureSpec({
|
|
1141
|
+
claim: "SELFTEST-CRASH",
|
|
1142
|
+
subject: "linked.txt",
|
|
1143
|
+
gate: ["bash", "gates/kill.sh", "crash"],
|
|
1144
|
+
});
|
|
1145
|
+
const sMover = fixtureSpec({
|
|
1146
|
+
claim: "SELFTEST-MOVER",
|
|
1147
|
+
gate: ["bash", "gates/mover.sh"],
|
|
1148
|
+
signature: "[QK:SELFTEST-MOVER]",
|
|
1149
|
+
signatureSource: "gates/mover.sh",
|
|
1150
|
+
});
|
|
1151
|
+
const all = [sA, sB, sC, sCrash, sMover];
|
|
1152
|
+
const collect = (name: string, requested: MutantSpec[], beforeGroup?: (i: number) => void) =>
|
|
1153
|
+
collectGroups({
|
|
1154
|
+
beforeGroup,
|
|
1155
|
+
repoDir: origin,
|
|
1156
|
+
selected: all,
|
|
1157
|
+
requested: requested.map((m) => m.gate),
|
|
1158
|
+
collectDir: path.join(tmpRoot, name),
|
|
1159
|
+
log: quiet,
|
|
1160
|
+
logError: quiet,
|
|
1161
|
+
tmpRoot,
|
|
1162
|
+
coreGroupGate: sA.gate,
|
|
1163
|
+
env: { GITHUB_RUN_ID: "123", GITHUB_RUN_ATTEMPT: "2" },
|
|
1164
|
+
});
|
|
1165
|
+
const receiptOf = (dir: string, file: string | null) =>
|
|
1166
|
+
JSON.parse(fs.readFileSync(path.join(tmpRoot, dir, file ?? ""), "utf8"));
|
|
1167
|
+
|
|
1168
|
+
const abc = await collect("abc", [sA, sB, sC]);
|
|
1169
|
+
ok(
|
|
1170
|
+
"[QK:COLLECT-RED-DOES-NOT-STOP] collect: green / red / green — the red middle does not stop the tail; collection COMPLETE and intact",
|
|
1171
|
+
abc.complete &&
|
|
1172
|
+
abc.stoppedBy === null &&
|
|
1173
|
+
JSON.stringify(abc.attempted.map((a) => a.outcome)) ===
|
|
1174
|
+
'["receipt-written","receipt-written","receipt-written"]' &&
|
|
1175
|
+
JSON.stringify(abc.attempted.map((a) => a.detail)) === '["group-green","group-RED","group-green"]' &&
|
|
1176
|
+
checkCollection(path.join(tmpRoot, "abc")).length === 0,
|
|
1177
|
+
);
|
|
1178
|
+
const rA = receiptOf("abc", abc.attempted[0].file);
|
|
1179
|
+
const rB = receiptOf("abc", abc.attempted[1].file);
|
|
1180
|
+
const rC = receiptOf("abc", abc.attempted[2].file);
|
|
1181
|
+
ok(
|
|
1182
|
+
"collect: each group ran control→mutant→control on its OWN clean snapshot, claims attributed",
|
|
1183
|
+
[rA, rC].every(
|
|
1184
|
+
(r) =>
|
|
1185
|
+
r.result.control === "ok" &&
|
|
1186
|
+
r.result.treeClean &&
|
|
1187
|
+
r.result.porcelainClean &&
|
|
1188
|
+
r.seal.stable &&
|
|
1189
|
+
r.result.mutants[0].verdict === "KILLED",
|
|
1190
|
+
) &&
|
|
1191
|
+
rB.result.control === "ok" &&
|
|
1192
|
+
rB.result.mutants[0].verdict === "SURVIVED" &&
|
|
1193
|
+
fs.readdirSync(tmpRoot).every((f) => !f.startsWith("entwurf-qualify-")),
|
|
1194
|
+
);
|
|
1195
|
+
ok(
|
|
1196
|
+
"collect: runId/runAttempt recorded exactly as the environment gave them; requested order kept",
|
|
1197
|
+
abc.runId === "123" &&
|
|
1198
|
+
abc.runAttempt === "2" &&
|
|
1199
|
+
JSON.stringify(abc.requested) === JSON.stringify([sA.gate, sB.gate, sC.gate]),
|
|
1200
|
+
);
|
|
1201
|
+
|
|
1202
|
+
const variant = (name: string, mutate: (dir: string) => void): string[] => {
|
|
1203
|
+
const dir = path.join(tmpRoot, name);
|
|
1204
|
+
fs.cpSync(path.join(tmpRoot, "abc"), dir, { recursive: true });
|
|
1205
|
+
mutate(dir);
|
|
1206
|
+
return checkCollection(dir);
|
|
1207
|
+
};
|
|
1208
|
+
const missing = variant("missing", (d) => fs.rmSync(path.join(d, "g002.json")));
|
|
1209
|
+
const extra = variant("extra", (d) => fs.writeFileSync(path.join(d, "stray.json"), "{}"));
|
|
1210
|
+
const altered = variant("altered", (d) => fs.appendFileSync(path.join(d, "g000.json"), " "));
|
|
1211
|
+
ok(
|
|
1212
|
+
"[QK:COLLECT-UNDECLARED-FILE-NAMED] collect: a missing, an undeclared and an altered receipt file are each named",
|
|
1213
|
+
missing.some((e) => e.startsWith("collection-missing:")) &&
|
|
1214
|
+
extra.some((e) => e === "collection-extra: stray.json") &&
|
|
1215
|
+
altered.some((e) => e.startsWith("collection-altered:")),
|
|
1216
|
+
);
|
|
1217
|
+
|
|
1218
|
+
const crash = await collect("crash", [sA, sCrash, sC]);
|
|
1219
|
+
const crashCheck = checkCollection(path.join(tmpRoot, "crash"));
|
|
1220
|
+
ok(
|
|
1221
|
+
"collect: a runner crash STOPS the collection — the rest not-attempted, INCOMPLETE on disk",
|
|
1222
|
+
crash.stoppedBy === "runner-crash" &&
|
|
1223
|
+
!crash.complete &&
|
|
1224
|
+
JSON.stringify(crash.attempted.map((a) => a.outcome)) ===
|
|
1225
|
+
'["receipt-written","runner-crash","not-attempted"]' &&
|
|
1226
|
+
crash.attempted[1].detail?.includes("symlink") === true &&
|
|
1227
|
+
crashCheck.some((e) => e.startsWith("collection-incomplete:")) &&
|
|
1228
|
+
crashCheck.includes("collection-stopped: runner-crash"),
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
const moved = await collect("moved", [sMover, sA]);
|
|
1232
|
+
ok(
|
|
1233
|
+
"[QK:COLLECT-SEAL-MOVED-STOPS] collect: an origin seal that moved STOPS the collection after writing that red receipt",
|
|
1234
|
+
moved.stoppedBy === "seal-moved" &&
|
|
1235
|
+
!moved.complete &&
|
|
1236
|
+
JSON.stringify(moved.attempted.map((a) => a.outcome)) === '["receipt-written","not-attempted"]' &&
|
|
1237
|
+
receiptOf("moved", moved.attempted[0].file).seal.stable === false,
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
// D1 — bytes move BETWEEN groups (each group alone is seal-stable): the collection must
|
|
1241
|
+
// stop, and a collection assembled from two candidates must never read as intact.
|
|
1242
|
+
const drift = await collect("drift", [sA, sC], (i) => {
|
|
1243
|
+
if (i === 1) fs.appendFileSync(path.join(origin, "untracked.txt"), "moved between groups\n");
|
|
1244
|
+
});
|
|
1245
|
+
ok(
|
|
1246
|
+
"[QK:COLLECT-CANDIDATE-DRIFT-STOPS] collect: origin bytes moved between two seal-stable groups → STOP candidate-drift, INCOMPLETE",
|
|
1247
|
+
drift.stoppedBy === "candidate-drift" &&
|
|
1248
|
+
!drift.complete &&
|
|
1249
|
+
drift.attempted[1].outcome === "receipt-written" &&
|
|
1250
|
+
(drift.attempted[1].detail ?? "").includes("candidate drift: workSurface") &&
|
|
1251
|
+
checkCollection(path.join(tmpRoot, "drift")).some((e) => e.startsWith("collection-candidate-drift:")),
|
|
1252
|
+
);
|
|
1253
|
+
const assembled = path.join(tmpRoot, "assembled");
|
|
1254
|
+
fs.cpSync(path.join(tmpRoot, "abc"), assembled, { recursive: true });
|
|
1255
|
+
fs.copyFileSync(path.join(tmpRoot, "drift", "g001.json"), path.join(assembled, "g002.json"));
|
|
1256
|
+
const assembledC = JSON.parse(fs.readFileSync(path.join(assembled, "collection.json"), "utf8"));
|
|
1257
|
+
assembledC.attempted[2].sha256 = createHash("sha256")
|
|
1258
|
+
.update(fs.readFileSync(path.join(assembled, "g002.json")))
|
|
1259
|
+
.digest("hex");
|
|
1260
|
+
fs.writeFileSync(path.join(assembled, "collection.json"), JSON.stringify(assembledC));
|
|
1261
|
+
const assembledCheck = checkCollection(path.join(tmpRoot, "assembled"));
|
|
1262
|
+
ok(
|
|
1263
|
+
"collect: a collection assembled from two REAL receipts of different candidates (digests re-declared) is named drift, never intact",
|
|
1264
|
+
assembledCheck.some((e) => e.startsWith("collection-candidate-drift:") && e.includes("g002.json")) &&
|
|
1265
|
+
!assembledCheck.some((e) => e.startsWith("collection-altered:")),
|
|
1266
|
+
);
|
|
1267
|
+
|
|
1268
|
+
// D2 — collection.json is data: its file names never steer a read outside the dir.
|
|
1269
|
+
const sentinel = path.join(tmpRoot, "outside.json");
|
|
1270
|
+
fs.copyFileSync(path.join(tmpRoot, "abc", "g000.json"), sentinel);
|
|
1271
|
+
const sentinelSha = createHash("sha256").update(fs.readFileSync(sentinel)).digest("hex");
|
|
1272
|
+
fs.chmodSync(sentinel, 0o000); // were it ever opened, the error would be EACCES, not the name refusal
|
|
1273
|
+
const redirect = (name: string, file: string, prep?: (dir: string) => void): string[] => {
|
|
1274
|
+
const dir = path.join(tmpRoot, name);
|
|
1275
|
+
fs.cpSync(path.join(tmpRoot, "abc"), dir, { recursive: true });
|
|
1276
|
+
const cj = JSON.parse(fs.readFileSync(path.join(dir, "collection.json"), "utf8"));
|
|
1277
|
+
cj.attempted[0].file = file;
|
|
1278
|
+
cj.attempted[0].sha256 = sentinelSha;
|
|
1279
|
+
fs.writeFileSync(path.join(dir, "collection.json"), JSON.stringify(cj));
|
|
1280
|
+
prep?.(dir);
|
|
1281
|
+
return checkCollection(dir);
|
|
1282
|
+
};
|
|
1283
|
+
const dotdot = redirect("esc-dotdot", "../outside.json");
|
|
1284
|
+
const absolute = redirect("esc-abs", sentinel);
|
|
1285
|
+
ok(
|
|
1286
|
+
"[QK:COLLECT-FILE-NAME-CONFINED] collect: a `../` or absolute receipt name is refused BY NAME — the outside sentinel is never opened",
|
|
1287
|
+
dotdot.some((e) => e.startsWith("collection-file-name:")) &&
|
|
1288
|
+
absolute.some((e) => e.startsWith("collection-file-name:")) &&
|
|
1289
|
+
![...dotdot, ...absolute].some((e) => e.includes("EACCES")),
|
|
1290
|
+
);
|
|
1291
|
+
fs.chmodSync(sentinel, 0o600);
|
|
1292
|
+
const linked = redirect("esc-link", "g000.json", (dir) => {
|
|
1293
|
+
fs.rmSync(path.join(dir, "g000.json"));
|
|
1294
|
+
fs.symlinkSync(sentinel, path.join(dir, "g000.json"));
|
|
1295
|
+
});
|
|
1296
|
+
const shapeDir = path.join(tmpRoot, "bad-shape");
|
|
1297
|
+
fs.cpSync(path.join(tmpRoot, "abc"), shapeDir, { recursive: true });
|
|
1298
|
+
fs.writeFileSync(path.join(shapeDir, "collection.json"), JSON.stringify({ attempted: "x" }));
|
|
1299
|
+
// Nested shape: a well-formed top level whose candidate groups are malformed must be
|
|
1300
|
+
// reported as shape, never thrown out of candidateDrift.
|
|
1301
|
+
const nestedShape = (name: string, groups: unknown): string[] => {
|
|
1302
|
+
const dir = path.join(tmpRoot, name);
|
|
1303
|
+
fs.cpSync(path.join(tmpRoot, "abc"), dir, { recursive: true });
|
|
1304
|
+
const cj = JSON.parse(fs.readFileSync(path.join(dir, "collection.json"), "utf8"));
|
|
1305
|
+
cj.candidate.groups = groups;
|
|
1306
|
+
fs.writeFileSync(path.join(dir, "collection.json"), JSON.stringify(cj));
|
|
1307
|
+
return checkCollection(dir);
|
|
1308
|
+
};
|
|
1309
|
+
const shapeNull = nestedShape("shape-null", [null]);
|
|
1310
|
+
const shapeGate = nestedShape("shape-gate", [{ gate: "x" }]);
|
|
1311
|
+
ok(
|
|
1312
|
+
"collect: a symlinked receipt is refused on the descriptor (ELOOP); a malformed collection.json is reported, never thrown",
|
|
1313
|
+
linked.some((e) => e.startsWith("collection-not-regular:") && e.includes("ELOOP")) &&
|
|
1314
|
+
JSON.stringify(shapeNull) ===
|
|
1315
|
+
'["collection-shape: collection.json is missing or mistyping a required field"]' &&
|
|
1316
|
+
JSON.stringify(shapeGate) ===
|
|
1317
|
+
'["collection-shape: collection.json is missing or mistyping a required field"]' &&
|
|
1318
|
+
JSON.stringify(checkCollection(shapeDir)) ===
|
|
1319
|
+
'["collection-shape: collection.json is missing or mistyping a required field"]',
|
|
1320
|
+
);
|
|
1321
|
+
|
|
1322
|
+
throws("collect: an empty request is refused", () => checkRequestedGroups([], all), "is empty");
|
|
1323
|
+
throws("collect: a repeated argv is refused", () => checkRequestedGroups([sA.gate, sA.gate], all), "repeats");
|
|
1324
|
+
throws(
|
|
1325
|
+
"collect: an argv no committed group has is refused",
|
|
1326
|
+
() => checkRequestedGroups([["bash", "gates/nope.sh"]], all),
|
|
1327
|
+
"no exact gate argv",
|
|
1328
|
+
);
|
|
1329
|
+
let exists = "";
|
|
1330
|
+
try {
|
|
1331
|
+
await collect("abc", [sA]);
|
|
1332
|
+
} catch (err) {
|
|
1333
|
+
exists = err instanceof Error ? err.message : String(err);
|
|
1334
|
+
}
|
|
1335
|
+
ok("collect: an existing collection dir is refused, never reused", exists.includes("EEXIST"));
|
|
1336
|
+
refuseArgs(["--groups", "[]", "--collect", "/x/c"], "is empty");
|
|
1337
|
+
refuseArgs(["--groups", '[["a"],["a"]]', "--collect", "/x/c"], "repeats");
|
|
1338
|
+
refuseArgs(["--groups", '["a"]', "--collect", "/x/c"], "non-empty argv arrays");
|
|
1339
|
+
refuseArgs(["--groups", '[["a"]]'], "--groups needs --collect");
|
|
1340
|
+
refuseArgs(["--collect", "/x/c"], "--collect needs --groups");
|
|
1341
|
+
refuseArgs(["--groups", '[["a"]]', "--collect", "/x/c", "--manifests-only"], "--groups cannot combine");
|
|
1342
|
+
} finally {
|
|
1343
|
+
fs.rmSync(origin, { recursive: true, force: true });
|
|
1344
|
+
fs.rmSync(path.dirname(externalTarget), { recursive: true, force: true });
|
|
1345
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// ═══ Phase 1c-l — the composite ledger over REAL fixture receipts (#124 P2) ═
|
|
1350
|
+
//
|
|
1351
|
+
// A late failure, end to end: receipts are written by runGroupReceipt on a fixture
|
|
1352
|
+
// origin (never hand-built), a gate is fixed, the ledger is re-composed on the new
|
|
1353
|
+
// bytes, the fixture is committed (dirty → clean, same content), and the ledger is
|
|
1354
|
+
// re-composed again. Then the pure negatives, each derived from one of those real
|
|
1355
|
+
// receipts. These live in the head for the same reason as Phase 1c-g (mixed process
|
|
1356
|
+
// surface; `test/**` is not on the public vitest door, run.sh check_tests_beside_behavior).
|
|
1357
|
+
|
|
1358
|
+
{
|
|
1359
|
+
const tmpRoot = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-ledger-selftest-")));
|
|
1360
|
+
const { dir: origin, externalTarget } = buildFixtureOrigin();
|
|
1361
|
+
try {
|
|
1362
|
+
const wr = (rel: string, body: string): void => fs.writeFileSync(path.join(origin, rel), body, { mode: 0o755 });
|
|
1363
|
+
wr("late.txt", "late-ok\n");
|
|
1364
|
+
wr("tail.txt", "tail-ok\n");
|
|
1365
|
+
// The late gate starts WEAK — it passes whatever late.txt says, so its mutant survives.
|
|
1366
|
+
wr("gates/late.sh", "#!/usr/bin/env bash\n# [QK:SELFTEST-LATE]\nexit 0\n");
|
|
1367
|
+
wr(
|
|
1368
|
+
"gates/tail.sh",
|
|
1369
|
+
'#!/usr/bin/env bash\nif grep -q tail-bad tail.txt; then echo "FAIL: tail defect [QK:SELFTEST-TAIL]" >&2; exit 1; fi\nexit 0\n',
|
|
1370
|
+
);
|
|
1371
|
+
const specA = fixtureSpec({});
|
|
1372
|
+
const specB = fixtureSpec({
|
|
1373
|
+
claim: "SELFTEST-LATE",
|
|
1374
|
+
subject: "late.txt",
|
|
1375
|
+
find: ["late-ok"],
|
|
1376
|
+
replace: ["late-bad"],
|
|
1377
|
+
gate: ["bash", "gates/late.sh"],
|
|
1378
|
+
signature: "[QK:SELFTEST-LATE]",
|
|
1379
|
+
signatureSource: "gates/late.sh",
|
|
1380
|
+
});
|
|
1381
|
+
const specC = fixtureSpec({
|
|
1382
|
+
claim: "SELFTEST-TAIL",
|
|
1383
|
+
subject: "tail.txt",
|
|
1384
|
+
find: ["tail-ok"],
|
|
1385
|
+
replace: ["tail-bad"],
|
|
1386
|
+
gate: ["bash", "gates/tail.sh"],
|
|
1387
|
+
signature: "[QK:SELFTEST-TAIL]",
|
|
1388
|
+
signatureSource: "gates/tail.sh",
|
|
1389
|
+
});
|
|
1390
|
+
const set = [specA, specB, specC];
|
|
1391
|
+
// The fixture's "runner group" (the role check-gate-manifests plays for real) is A.
|
|
1392
|
+
const current = () => currentCandidate(origin, set, specA.gate);
|
|
1393
|
+
let seq = 0;
|
|
1394
|
+
const measure = async (spec: MutantSpec): Promise<string> => {
|
|
1395
|
+
const file = path.join(tmpRoot, `r${seq++}.json`);
|
|
1396
|
+
await runGroupReceipt({
|
|
1397
|
+
repoDir: origin,
|
|
1398
|
+
selected: set,
|
|
1399
|
+
gate: spec.gate,
|
|
1400
|
+
receiptPath: file,
|
|
1401
|
+
log: quiet,
|
|
1402
|
+
logError: quiet,
|
|
1403
|
+
tmpRoot,
|
|
1404
|
+
});
|
|
1405
|
+
return file;
|
|
1406
|
+
};
|
|
1407
|
+
const compose = (files: string[], cur = current()) => composeLedger(files.map(loadReceipt), cur);
|
|
1408
|
+
const group = (l: ReturnType<typeof compose>, spec: MutantSpec) =>
|
|
1409
|
+
l.groups.find((g) => JSON.stringify(g.gate) === JSON.stringify(spec.gate));
|
|
1410
|
+
const hasBlocker = (l: ReturnType<typeof compose>, prefix: string) => l.blockers.some((b) => b.startsWith(prefix));
|
|
1411
|
+
|
|
1412
|
+
// X1 (dirty): A green, B red (the late failure), C never run.
|
|
1413
|
+
const rA1 = await measure(specA);
|
|
1414
|
+
const rB1 = await measure(specB);
|
|
1415
|
+
const x1 = compose([rA1, rB1]);
|
|
1416
|
+
ok(
|
|
1417
|
+
"ledger X1: measured green, late red, unrun tail → RED; nothing CARRIED",
|
|
1418
|
+
x1.verdict === "RED" &&
|
|
1419
|
+
group(x1, specA)?.state === "MEASURED" &&
|
|
1420
|
+
group(x1, specB)?.state === "FAIL" &&
|
|
1421
|
+
group(x1, specC)?.state === "UNRUN" &&
|
|
1422
|
+
x1.counts.groups.CARRIED === 0 &&
|
|
1423
|
+
x1.summary.some((s) => s.includes("FAIL bash gates/late.sh") && s.includes("SELFTEST-LATE=SURVIVED")),
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
// The local fix: the late gate now detects its defect. New bytes → X2 (still dirty).
|
|
1427
|
+
wr(
|
|
1428
|
+
"gates/late.sh",
|
|
1429
|
+
'#!/usr/bin/env bash\nif grep -q late-bad late.txt; then echo "FAIL: late defect [QK:SELFTEST-LATE]" >&2; exit 1; fi\nexit 0\n',
|
|
1430
|
+
);
|
|
1431
|
+
const x2 = compose([rA1, rB1]);
|
|
1432
|
+
ok(
|
|
1433
|
+
"ledger X2 before rerun: A only c-core INHERITED, B's old red not current → NOT-QUALIFIED (dirty, runner group, red-unremeasured)",
|
|
1434
|
+
x2.verdict === "NOT-QUALIFIED" &&
|
|
1435
|
+
group(x2, specA)?.state === "INHERITED-UNVERIFIED" &&
|
|
1436
|
+
group(x2, specA)?.inheritKind === "c-core" &&
|
|
1437
|
+
group(x2, specB)?.state === "UNRUN" &&
|
|
1438
|
+
group(x2, specB)?.history[0].passed === false &&
|
|
1439
|
+
hasBlocker(x2, "candidate-dirty") &&
|
|
1440
|
+
hasBlocker(x2, "core-group-not-measured") &&
|
|
1441
|
+
hasBlocker(x2, "red-unremeasured: bash gates/late.sh"),
|
|
1442
|
+
);
|
|
1443
|
+
|
|
1444
|
+
// Re-measure the fixed section, the never-run tail, then the runner group — not from 1.
|
|
1445
|
+
const rB2 = await measure(specB);
|
|
1446
|
+
const rC2 = await measure(specC);
|
|
1447
|
+
const rA2 = await measure(specA);
|
|
1448
|
+
const x2c = compose([rA1, rB1, rB2, rC2, rA2]);
|
|
1449
|
+
ok(
|
|
1450
|
+
"ledger X2 all exact-MEASURED but dirty → NOT-QUALIFIED with only the dirty blocker; B keeps its red in history",
|
|
1451
|
+
x2c.verdict === "NOT-QUALIFIED" &&
|
|
1452
|
+
x2c.counts.groups.MEASURED === 3 &&
|
|
1453
|
+
x2c.blockers.length === 1 &&
|
|
1454
|
+
hasBlocker(x2c, "candidate-dirty") &&
|
|
1455
|
+
(group(x2c, specB)?.history.filter((h) => !h.passed).length ?? 0) === 1,
|
|
1456
|
+
);
|
|
1457
|
+
|
|
1458
|
+
// Commit the fixture: same content, new HEAD, clean porcelain → Y.
|
|
1459
|
+
const surfaceX2 = current().workSurfaceSha256;
|
|
1460
|
+
const headX2 = current().head;
|
|
1461
|
+
for (const args of [
|
|
1462
|
+
["add", "-A"],
|
|
1463
|
+
["-c", "commit.gpgsign=false", "commit", "-q", "-m", "fixture: late fix"],
|
|
1464
|
+
]) {
|
|
1465
|
+
const r = spawnSync("git", ["-C", origin, ...args], {
|
|
1466
|
+
stdio: "ignore",
|
|
1467
|
+
env: {
|
|
1468
|
+
...process.env,
|
|
1469
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
1470
|
+
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
1471
|
+
GIT_AUTHOR_NAME: "fx",
|
|
1472
|
+
GIT_AUTHOR_EMAIL: "fx@localhost",
|
|
1473
|
+
GIT_COMMITTER_NAME: "fx",
|
|
1474
|
+
GIT_COMMITTER_EMAIL: "fx@localhost",
|
|
1475
|
+
},
|
|
1476
|
+
});
|
|
1477
|
+
assert.equal(r.status, 0, `fixture git ${args.join(" ")} failed`);
|
|
1478
|
+
}
|
|
1479
|
+
const y = current();
|
|
1480
|
+
ok(
|
|
1481
|
+
"ledger commit: the same content committed keeps the work surface, moves HEAD, and is clean",
|
|
1482
|
+
y.workSurfaceSha256 === surfaceX2 && y.head !== headX2 && y.porcelainClean,
|
|
1483
|
+
);
|
|
1484
|
+
const y1 = compose([rA1, rB1, rB2, rC2, rA2], y);
|
|
1485
|
+
ok(
|
|
1486
|
+
"ledger Y from pre-commit receipts: content-identical provenance only, flagged; runner group not exact → NOT-QUALIFIED",
|
|
1487
|
+
y1.verdict === "NOT-QUALIFIED" &&
|
|
1488
|
+
y1.groups.every((g) => g.state === "MEASURED" && g.provenance === "content-identical") &&
|
|
1489
|
+
y1.groups.every((g) => g.flags.some((f) => f.startsWith("content-identical(head "))) &&
|
|
1490
|
+
y1.blockers.length === 1 &&
|
|
1491
|
+
hasBlocker(y1, "core-group-not-measured"),
|
|
1492
|
+
);
|
|
1493
|
+
const rA3 = await measure(specA);
|
|
1494
|
+
const y2 = compose([rA1, rB1, rB2, rC2, rA2, rA3], y);
|
|
1495
|
+
ok(
|
|
1496
|
+
"[QK:LEDGER-FLAG-BLOCKS-GREEN] ledger Y with the runner group exact but B/C content-identical → RISK-PENDING, never green",
|
|
1497
|
+
y2.verdict === "RISK-PENDING" &&
|
|
1498
|
+
group(y2, specA)?.provenance === "exact" &&
|
|
1499
|
+
y2.risks.filter((r) => r.includes("content-identical(head ")).length === 2 &&
|
|
1500
|
+
y2.summary.some((s) => s.includes("accepts nothing and authorizes no release")),
|
|
1501
|
+
);
|
|
1502
|
+
const rB3 = await measure(specB);
|
|
1503
|
+
const rC3 = await measure(specC);
|
|
1504
|
+
const y3 = compose([rA1, rB1, rB2, rC2, rA2, rA3, rB3, rC3], y);
|
|
1505
|
+
ok(
|
|
1506
|
+
"ledger Y all exact-MEASURED on the committed bytes → COMPOSITE-GREEN, labelled per-group (not the serial body)",
|
|
1507
|
+
y3.verdict === "COMPOSITE-GREEN" &&
|
|
1508
|
+
y3.groups.every((g) => g.provenance === "exact") &&
|
|
1509
|
+
y3.counts.claims.MEASURED === 3 &&
|
|
1510
|
+
y3.summary.some((s) => s.includes("not the serial full body")),
|
|
1511
|
+
);
|
|
1512
|
+
|
|
1513
|
+
// ── pure negatives, each derived from a REAL receipt; `cur` is clean Y ──
|
|
1514
|
+
const cur = y;
|
|
1515
|
+
const text = (f: string): string => fs.readFileSync(f, "utf8");
|
|
1516
|
+
const reseal = (f: string, patch: (r: Record<string, unknown>) => void): string => {
|
|
1517
|
+
const { receiptSha256: _drop, ...body } = JSON.parse(text(f));
|
|
1518
|
+
patch(body);
|
|
1519
|
+
return JSON.stringify(sealReceipt(body));
|
|
1520
|
+
};
|
|
1521
|
+
const green = [rA3, rB3, rC3].map((f) => ({ source: f, text: text(f) }));
|
|
1522
|
+
const withA = (a: string) => [{ source: "forged", text: a }, green[1], green[2]];
|
|
1523
|
+
const invalidCode = (l: ReturnType<typeof composeLedger>) => l.invalid.map((i) => i.code);
|
|
1524
|
+
const all: ReturnType<typeof composeLedger>[] = [x1, x2, x2c, y1, y2, y3];
|
|
1525
|
+
const keep = (l: ReturnType<typeof composeLedger>) => {
|
|
1526
|
+
all.push(l);
|
|
1527
|
+
return l;
|
|
1528
|
+
};
|
|
1529
|
+
|
|
1530
|
+
const dup = keep(composeLedger([...green, green[0]], cur));
|
|
1531
|
+
ok(
|
|
1532
|
+
"[QK:LEDGER-INVALID-BLOCKS-GREEN] ledger: a duplicated receipt beside all-exact-MEASURED groups is RED (fail-closed), never green",
|
|
1533
|
+
JSON.stringify(invalidCode(dup)) === '["R-DUPLICATE"]' &&
|
|
1534
|
+
dup.verdict === "RED" &&
|
|
1535
|
+
dup.counts.groups.MEASURED === 3,
|
|
1536
|
+
);
|
|
1537
|
+
const withBroken = keep(composeLedger([...green, { source: "broken", text: "{" }], cur));
|
|
1538
|
+
ok(
|
|
1539
|
+
"ledger: one broken receipt beside all-MEASURED groups → RED, the INVALID line kept",
|
|
1540
|
+
withBroken.verdict === "RED" && withBroken.summary.some((s) => s.startsWith(" INVALID broken: R-PARSE")),
|
|
1541
|
+
);
|
|
1542
|
+
const bad = keep(
|
|
1543
|
+
composeLedger(
|
|
1544
|
+
[
|
|
1545
|
+
{ source: "p", text: "{" },
|
|
1546
|
+
{
|
|
1547
|
+
source: "s",
|
|
1548
|
+
text: reseal(rA3, (b) => {
|
|
1549
|
+
b.schema = "other";
|
|
1550
|
+
}),
|
|
1551
|
+
},
|
|
1552
|
+
{
|
|
1553
|
+
source: "h",
|
|
1554
|
+
text: reseal(rA3, (b) => {
|
|
1555
|
+
delete b.result;
|
|
1556
|
+
}),
|
|
1557
|
+
},
|
|
1558
|
+
{ source: "d", text: text(rA3).replace('"verdict": "KILLED"', '"verdict": "SURVIVED"') },
|
|
1559
|
+
],
|
|
1560
|
+
cur,
|
|
1561
|
+
),
|
|
1562
|
+
);
|
|
1563
|
+
ok(
|
|
1564
|
+
"[QK:LEDGER-DIGEST-CHECKED] ledger: parse/schema/shape/digest failures are INVALID and count for nothing",
|
|
1565
|
+
JSON.stringify(invalidCode(bad)) === '["R-PARSE","R-SCHEMA","R-SHAPE","R-DIGEST"]' &&
|
|
1566
|
+
bad.counts.groups.UNRUN === 3 &&
|
|
1567
|
+
bad.verdict === "RED",
|
|
1568
|
+
);
|
|
1569
|
+
const forgedPass = reseal(rB1, (b) => {
|
|
1570
|
+
(b.result as { passed: boolean }).passed = true;
|
|
1571
|
+
});
|
|
1572
|
+
const dropped = reseal(rA3, (b) => {
|
|
1573
|
+
(b.result as { mutants: unknown[] }).mutants = [];
|
|
1574
|
+
});
|
|
1575
|
+
const unknown = reseal(rA3, (b) => {
|
|
1576
|
+
(b.group as { gate: string[] }).gate = ["bash", "gates/nope.sh"];
|
|
1577
|
+
});
|
|
1578
|
+
// A receipt whose seal moved but still claims passed: the candidate it measured
|
|
1579
|
+
// was not stable, so it can never count as a pass.
|
|
1580
|
+
const sealMovedPass = reseal(rA3, (b) => {
|
|
1581
|
+
(b.seal as { stable: boolean }).stable = false;
|
|
1582
|
+
});
|
|
1583
|
+
const inc = keep(
|
|
1584
|
+
composeLedger(
|
|
1585
|
+
[
|
|
1586
|
+
{ source: "fp", text: forgedPass },
|
|
1587
|
+
{ source: "dr", text: dropped },
|
|
1588
|
+
{ source: "uk", text: unknown },
|
|
1589
|
+
{ source: "sm", text: sealMovedPass },
|
|
1590
|
+
],
|
|
1591
|
+
cur,
|
|
1592
|
+
),
|
|
1593
|
+
);
|
|
1594
|
+
ok(
|
|
1595
|
+
"[QK:LEDGER-SEAL-IN-PASS-RECOMPUTE] ledger: a red re-labelled passed, mutants dropped, an unknown argv, a moved seal still claiming passed — all INVALID, never green",
|
|
1596
|
+
JSON.stringify(invalidCode(inc)) ===
|
|
1597
|
+
'["R-RESULT-INCONSISTENT","R-RESULT-INCONSISTENT","R-UNKNOWN-GROUP","R-RESULT-INCONSISTENT"]' &&
|
|
1598
|
+
inc.counts.groups.MEASURED === 0 &&
|
|
1599
|
+
inc.verdict === "RED",
|
|
1600
|
+
);
|
|
1601
|
+
const earlierRed = reseal(rA3, (b) => {
|
|
1602
|
+
const r = b.result as { passed: boolean; mutants: { verdict: string }[] };
|
|
1603
|
+
r.mutants[0].verdict = "SURVIVED";
|
|
1604
|
+
r.passed = false;
|
|
1605
|
+
b.startedAt = "2000-01-01T00:00:00.000Z";
|
|
1606
|
+
});
|
|
1607
|
+
const flaky = keep(composeLedger([...withA(earlierRed), green[0]], cur));
|
|
1608
|
+
ok(
|
|
1609
|
+
"ledger: red then green on identical bytes → MEASURED but flagged, RISK-PENDING (nondeterminism blocks green)",
|
|
1610
|
+
flaky.verdict === "RISK-PENDING" &&
|
|
1611
|
+
flaky.groups[0].state === "MEASURED" &&
|
|
1612
|
+
flaky.groups[0].flags.includes("red-then-green-same-candidate") &&
|
|
1613
|
+
flaky.groups[0].history.length === 2,
|
|
1614
|
+
);
|
|
1615
|
+
const aStarted = JSON.parse(text(rA3)).startedAt as string;
|
|
1616
|
+
const tieRed = reseal(rA3, (b) => {
|
|
1617
|
+
const r = b.result as { passed: boolean; mutants: { verdict: string }[] };
|
|
1618
|
+
r.mutants[0].verdict = "SURVIVED";
|
|
1619
|
+
r.passed = false;
|
|
1620
|
+
b.startedAt = aStarted;
|
|
1621
|
+
});
|
|
1622
|
+
const tie = keep(composeLedger([...withA(tieRed), green[0]], cur));
|
|
1623
|
+
ok(
|
|
1624
|
+
"ledger: two matching receipts at the same instant that disagree → FAIL `tie`, never an arbitrary pick",
|
|
1625
|
+
tie.verdict === "RED" && tie.groups[0].state === "FAIL" && tie.groups[0].reasons[0].startsWith("tie:"),
|
|
1626
|
+
);
|
|
1627
|
+
const drift = reseal(rA3, (b) => {
|
|
1628
|
+
(b.seal as { nodeModulesExcludedDrift: string[] }).nodeModulesExcludedDrift = [".vite/vitest/x/results.json"];
|
|
1629
|
+
});
|
|
1630
|
+
const dr = keep(composeLedger(withA(drift), cur));
|
|
1631
|
+
ok(
|
|
1632
|
+
"ledger: observed .vite drift flags cache-drift → RISK-PENDING, not green",
|
|
1633
|
+
dr.verdict === "RISK-PENDING" && dr.groups[0].state === "MEASURED" && dr.groups[0].flags.includes("cache-drift"),
|
|
1634
|
+
);
|
|
1635
|
+
|
|
1636
|
+
// Seam 1 negatives: content identity needs a CLEAN candidate and the same environment.
|
|
1637
|
+
const dirtyY = keep(composeLedger([rA2, rB2, rC2].map(loadReceipt), { ...cur, porcelainClean: false }));
|
|
1638
|
+
ok(
|
|
1639
|
+
"ledger: same surface, different HEAD, but a DIRTY candidate → no content-identical, c-core INHERITED, NOT-QUALIFIED",
|
|
1640
|
+
dirtyY.verdict === "NOT-QUALIFIED" &&
|
|
1641
|
+
dirtyY.groups.every((g) => g.state === "INHERITED-UNVERIFIED" && g.inheritKind === "c-core") &&
|
|
1642
|
+
dirtyY.groups.every((g) => g.provenance === null) &&
|
|
1643
|
+
hasBlocker(dirtyY, "candidate-dirty"),
|
|
1644
|
+
);
|
|
1645
|
+
const nodeMoved = keep(composeLedger(green, { ...cur, coreSha256: "0".repeat(64) }));
|
|
1646
|
+
ok(
|
|
1647
|
+
"ledger: a different node/node_modules/C0 identity → every group c-core INHERITED, never CARRIED, NOT-QUALIFIED",
|
|
1648
|
+
nodeMoved.verdict === "NOT-QUALIFIED" &&
|
|
1649
|
+
nodeMoved.groups.every((g) => g.inheritKind === "c-core" && g.reasons[0].includes("core")) &&
|
|
1650
|
+
nodeMoved.counts.groups.CARRIED === 0,
|
|
1651
|
+
);
|
|
1652
|
+
// Seam 4: c-input must be re-measured; c-core/UNRUN are only named risks.
|
|
1653
|
+
const inputMoved = keep(
|
|
1654
|
+
composeLedger(green, {
|
|
1655
|
+
...cur,
|
|
1656
|
+
groups: cur.groups.map((g, i) =>
|
|
1657
|
+
i === 1 ? { ...g, declaredInputs: { ...g.declaredInputs, "gates/late.sh": "0".repeat(64) } } : g,
|
|
1658
|
+
),
|
|
1659
|
+
}),
|
|
1660
|
+
);
|
|
1661
|
+
ok(
|
|
1662
|
+
"ledger: a declared input moved (c-input) → blocker `re-measure`, NOT-QUALIFIED, not a risk to accept",
|
|
1663
|
+
inputMoved.verdict === "NOT-QUALIFIED" &&
|
|
1664
|
+
inputMoved.groups[1].inheritKind === "c-input" &&
|
|
1665
|
+
hasBlocker(inputMoved, "c-input: bash gates/late.sh"),
|
|
1666
|
+
);
|
|
1667
|
+
const surfaceOnlyB = reseal(rB3, (b) => {
|
|
1668
|
+
(b.candidate as { workSurfaceSha256: string }).workSurfaceSha256 = "0".repeat(64);
|
|
1669
|
+
});
|
|
1670
|
+
const riskOnly = keep(composeLedger([green[0], { source: "b-core", text: surfaceOnlyB }], cur));
|
|
1671
|
+
ok(
|
|
1672
|
+
"ledger: runner group exact, B only c-core, C unrun → RISK-PENDING listing exactly those risks",
|
|
1673
|
+
riskOnly.verdict === "RISK-PENDING" &&
|
|
1674
|
+
riskOnly.blockers.length === 0 &&
|
|
1675
|
+
riskOnly.risks.some((r) => r.startsWith("c-core: bash gates/late.sh")) &&
|
|
1676
|
+
riskOnly.risks.some((r) => r.startsWith("unrun: bash gates/tail.sh")),
|
|
1677
|
+
);
|
|
1678
|
+
const unresolved = keep(
|
|
1679
|
+
composeLedger(green, {
|
|
1680
|
+
...cur,
|
|
1681
|
+
groups: cur.groups.map((g, i) =>
|
|
1682
|
+
i === 0 ? { ...g, declaredInputs: { ...g.declaredInputs, "x.txt": "outside-repo" } } : g,
|
|
1683
|
+
),
|
|
1684
|
+
}),
|
|
1685
|
+
);
|
|
1686
|
+
ok(
|
|
1687
|
+
"ledger: an unresolved declared input (outside-repo/missing/link) can never be MEASURED-green",
|
|
1688
|
+
unresolved.verdict !== "COMPOSITE-GREEN" && unresolved.groups[0].state !== "MEASURED",
|
|
1689
|
+
);
|
|
1690
|
+
const specMoved = keep(
|
|
1691
|
+
composeLedger(green, {
|
|
1692
|
+
...cur,
|
|
1693
|
+
groups: cur.groups.map((g, i) => (i === 2 ? { ...g, specSetSha256: "0".repeat(64) } : g)),
|
|
1694
|
+
}),
|
|
1695
|
+
);
|
|
1696
|
+
ok(
|
|
1697
|
+
"ledger: a changed spec set INVALIDATES that group (UNRUN), not INHERITED; the others stay MEASURED",
|
|
1698
|
+
specMoved.groups[2].state === "UNRUN" &&
|
|
1699
|
+
specMoved.groups[0].state === "MEASURED" &&
|
|
1700
|
+
specMoved.verdict === "RISK-PENDING",
|
|
1701
|
+
);
|
|
1702
|
+
|
|
1703
|
+
// Seam 2: an explicit input list is fail-closed.
|
|
1704
|
+
const listOf = (xs: { source: string; text: string }[]) =>
|
|
1705
|
+
xs.map((x) => `${createHash("sha256").update(x.text).digest("hex")} ${x.source}`).join("\n");
|
|
1706
|
+
const expectOk = keep(composeLedger(green, cur, parseExpectedList(listOf(green))));
|
|
1707
|
+
const expectMissing = keep(composeLedger(green.slice(0, 2), cur, parseExpectedList(listOf(green))));
|
|
1708
|
+
const expectExtra = keep(composeLedger(green, cur, parseExpectedList(listOf(green.slice(0, 2)))));
|
|
1709
|
+
const expectDigest = keep(
|
|
1710
|
+
composeLedger(green, cur, parseExpectedList(listOf(green).replace(/^[0-9a-f]{64}/, "0".repeat(64)))),
|
|
1711
|
+
);
|
|
1712
|
+
ok(
|
|
1713
|
+
"ledger inputs: the declared list matches → no input error; missing / unexpected / altered input → RED",
|
|
1714
|
+
expectOk.verdict === "COMPOSITE-GREEN" &&
|
|
1715
|
+
expectOk.inputErrors.length === 0 &&
|
|
1716
|
+
expectOk.inputs.every((i) => i.provenance === "local-file") &&
|
|
1717
|
+
expectMissing.verdict === "RED" &&
|
|
1718
|
+
expectMissing.inputErrors.some((e) => e.startsWith("input-missing:")) &&
|
|
1719
|
+
expectExtra.verdict === "RED" &&
|
|
1720
|
+
expectExtra.inputErrors.some((e) => e.startsWith("input-unexpected:")) &&
|
|
1721
|
+
expectDigest.verdict === "RED" &&
|
|
1722
|
+
expectDigest.inputErrors.some((e) => e.startsWith("input-digest:")),
|
|
1723
|
+
);
|
|
1724
|
+
throws(
|
|
1725
|
+
"ledger inputs: a malformed expected-input line is refused",
|
|
1726
|
+
() => parseExpectedList("not-a-digest x.json"),
|
|
1727
|
+
"is not `<sha256> <path>`",
|
|
1728
|
+
);
|
|
1729
|
+
// Display: the UNRUN list may be compressed on the console only when the full ledger
|
|
1730
|
+
// is written somewhere; a RISK-PENDING without that path prints everything.
|
|
1731
|
+
const wide = {
|
|
1732
|
+
...cur,
|
|
1733
|
+
groups: [
|
|
1734
|
+
...cur.groups,
|
|
1735
|
+
...Array.from({ length: 7 }, (_, i) => ({
|
|
1736
|
+
gate: ["bash", `gates/fake-${i}.sh`],
|
|
1737
|
+
claims: [`FAKE-${i}`],
|
|
1738
|
+
specSetSha256: "0".repeat(64),
|
|
1739
|
+
declaredInputs: {},
|
|
1740
|
+
})),
|
|
1741
|
+
],
|
|
1742
|
+
};
|
|
1743
|
+
const wideFull = keep(composeLedger([green[0]], wide));
|
|
1744
|
+
const wideShort = keep(composeLedger([green[0]], wide, undefined, { ledgerPath: "/tmp/x-ledger.json" }));
|
|
1745
|
+
const unrunLines = (l: ReturnType<typeof composeLedger>) => l.summary.filter((s) => s.startsWith(" RISK unrun:"));
|
|
1746
|
+
ok(
|
|
1747
|
+
"ledger display: 9 unrun groups print in full without a ledger path, as one counted line naming it with one; nothing drops from risks/groups",
|
|
1748
|
+
wideFull.verdict === "RISK-PENDING" &&
|
|
1749
|
+
unrunLines(wideFull).length === 9 &&
|
|
1750
|
+
unrunLines(wideShort).length === 1 &&
|
|
1751
|
+
unrunLines(wideShort)[0].includes("9 groups") &&
|
|
1752
|
+
unrunLines(wideShort)[0].includes("full list: /tmp/x-ledger.json") &&
|
|
1753
|
+
wideShort.risks.filter((r) => r.startsWith("unrun: ")).length === 9 &&
|
|
1754
|
+
wideShort.groups.filter((g) => g.state === "UNRUN").length === 9,
|
|
1755
|
+
);
|
|
1756
|
+
ok(
|
|
1757
|
+
"ledger: no local composition above is FULL-GREEN or RISK-ACCEPTED, and CARRIED is never populated",
|
|
1758
|
+
all.every(
|
|
1759
|
+
(l) =>
|
|
1760
|
+
["RED", "NOT-QUALIFIED", "RISK-PENDING", "COMPOSITE-GREEN"].includes(l.verdict) &&
|
|
1761
|
+
l.counts.groups.CARRIED === 0 &&
|
|
1762
|
+
!l.summary.some((s) => /FULL-GREEN|RISK-ACCEPTED|\b788\/788\b/.test(s)),
|
|
1763
|
+
),
|
|
1764
|
+
);
|
|
1765
|
+
} finally {
|
|
1766
|
+
fs.rmSync(origin, { recursive: true, force: true });
|
|
1767
|
+
fs.rmSync(path.dirname(externalTarget), { recursive: true, force: true });
|
|
1768
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// ═══ Phase 1c-r — the RELEASE composition over real CI-shaped collections (#124 step 2) ═
|
|
1773
|
+
//
|
|
1774
|
+
// Fixture origin committed twice (X1, then the late fix X2) on one first-parent chain.
|
|
1775
|
+
// Collections are written by the production collectGroups with CI-shaped run ids; the
|
|
1776
|
+
// pure composeRelease is then fed those files. Negatives derive from the same files.
|
|
1777
|
+
|
|
1778
|
+
{
|
|
1779
|
+
const tmpRoot = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-release-selftest-")));
|
|
1780
|
+
const { dir: origin, externalTarget } = buildFixtureOrigin();
|
|
1781
|
+
try {
|
|
1782
|
+
const gitFx = (...args: string[]): string => {
|
|
1783
|
+
const r = spawnSync("git", ["-C", origin, ...args], {
|
|
1784
|
+
encoding: "utf8",
|
|
1785
|
+
env: {
|
|
1786
|
+
...process.env,
|
|
1787
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
1788
|
+
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
1789
|
+
GIT_AUTHOR_NAME: "fx",
|
|
1790
|
+
GIT_AUTHOR_EMAIL: "fx@localhost",
|
|
1791
|
+
GIT_COMMITTER_NAME: "fx",
|
|
1792
|
+
GIT_COMMITTER_EMAIL: "fx@localhost",
|
|
1793
|
+
},
|
|
1794
|
+
});
|
|
1795
|
+
assert.equal(r.status, 0, `fixture git ${args.join(" ")} failed: ${r.stderr}`);
|
|
1796
|
+
return r.stdout.trim();
|
|
1797
|
+
};
|
|
1798
|
+
const commit = (msg: string): string => {
|
|
1799
|
+
gitFx("add", "-A");
|
|
1800
|
+
gitFx("-c", "commit.gpgsign=false", "commit", "-q", "-m", msg);
|
|
1801
|
+
return gitFx("rev-parse", "HEAD");
|
|
1802
|
+
};
|
|
1803
|
+
const wr = (rel: string, body: string): void => fs.writeFileSync(path.join(origin, rel), body, { mode: 0o755 });
|
|
1804
|
+
wr("late.txt", "late-ok\n");
|
|
1805
|
+
wr("tail.txt", "tail-ok\n");
|
|
1806
|
+
wr("gates/late.sh", "#!/usr/bin/env bash\n# [QK:SELFTEST-LATE]\nexit 0\n");
|
|
1807
|
+
wr(
|
|
1808
|
+
"gates/tail.sh",
|
|
1809
|
+
'#!/usr/bin/env bash\nif grep -q tail-bad tail.txt; then echo "FAIL: tail defect [QK:SELFTEST-TAIL]" >&2; exit 1; fi\nexit 0\n',
|
|
1810
|
+
);
|
|
1811
|
+
const sA = fixtureSpec({});
|
|
1812
|
+
const sB = fixtureSpec({
|
|
1813
|
+
claim: "SELFTEST-LATE",
|
|
1814
|
+
subject: "late.txt",
|
|
1815
|
+
find: ["late-ok"],
|
|
1816
|
+
replace: ["late-bad"],
|
|
1817
|
+
gate: ["bash", "gates/late.sh"],
|
|
1818
|
+
signature: "[QK:SELFTEST-LATE]",
|
|
1819
|
+
signatureSource: "gates/late.sh",
|
|
1820
|
+
});
|
|
1821
|
+
const tailLike = (claim: string, extra: string[]) =>
|
|
1822
|
+
fixtureSpec({
|
|
1823
|
+
claim,
|
|
1824
|
+
subject: "tail.txt",
|
|
1825
|
+
find: ["tail-ok"],
|
|
1826
|
+
replace: ["tail-bad"],
|
|
1827
|
+
gate: ["bash", "gates/tail.sh", ...extra],
|
|
1828
|
+
signature: "[QK:SELFTEST-TAIL]",
|
|
1829
|
+
signatureSource: "gates/tail.sh",
|
|
1830
|
+
});
|
|
1831
|
+
const sC = tailLike("SELFTEST-TAIL", []);
|
|
1832
|
+
const sD = tailLike("SELFTEST-TAIL-D", ["d"]);
|
|
1833
|
+
const set = [sA, sB, sC, sD];
|
|
1834
|
+
const collectRun = async (name: string, runId: string, requested: MutantSpec[]) => {
|
|
1835
|
+
const dir = path.join(tmpRoot, name);
|
|
1836
|
+
await collectGroups({
|
|
1837
|
+
repoDir: origin,
|
|
1838
|
+
selected: set,
|
|
1839
|
+
requested: requested.map((m) => m.gate),
|
|
1840
|
+
collectDir: dir,
|
|
1841
|
+
log: quiet,
|
|
1842
|
+
logError: quiet,
|
|
1843
|
+
tmpRoot,
|
|
1844
|
+
coreGroupGate: sA.gate,
|
|
1845
|
+
env: { GITHUB_RUN_ID: runId, GITHUB_RUN_ATTEMPT: "1" },
|
|
1846
|
+
});
|
|
1847
|
+
return dir;
|
|
1848
|
+
};
|
|
1849
|
+
const asRun = (dir: string, headSha: string, runId: string, runAttempt = "1"): CiRun => ({
|
|
1850
|
+
runId,
|
|
1851
|
+
runAttempt,
|
|
1852
|
+
headSha,
|
|
1853
|
+
files: Object.fromEntries(fs.readdirSync(dir).map((f) => [f, fs.readFileSync(path.join(dir, f))])),
|
|
1854
|
+
});
|
|
1855
|
+
const editCollection = (run: CiRun, patch: (c: Record<string, any>) => void): CiRun => {
|
|
1856
|
+
const c = JSON.parse(run.files["collection.json"].toString("utf8"));
|
|
1857
|
+
patch(c);
|
|
1858
|
+
return { ...run, files: { ...run.files, "collection.json": Buffer.from(JSON.stringify(c)) } };
|
|
1859
|
+
};
|
|
1860
|
+
|
|
1861
|
+
// X1: the late gate is weak → B red. Two CI runs at X1 (one without D).
|
|
1862
|
+
const x1 = commit("X1");
|
|
1863
|
+
const d100 = await collectRun("r100", "100", [sA, sB, sC, sD]);
|
|
1864
|
+
const d101 = await collectRun("r101", "101", [sA, sB, sC]);
|
|
1865
|
+
const firstParentX1 = gitFx("rev-list", "--first-parent", "HEAD").split("\n");
|
|
1866
|
+
const independentX1 = currentCandidate(origin, set, sA.gate);
|
|
1867
|
+
// X2: the late fix, committed. Run 200 re-measures runner + the red group; 201 only the runner.
|
|
1868
|
+
wr(
|
|
1869
|
+
"gates/late.sh",
|
|
1870
|
+
'#!/usr/bin/env bash\nif grep -q late-bad late.txt; then echo "FAIL: late defect [QK:SELFTEST-LATE]" >&2; exit 1; fi\nexit 0\n',
|
|
1871
|
+
);
|
|
1872
|
+
const x2 = commit("X2 late fix");
|
|
1873
|
+
const d200 = await collectRun("r200", "200", [sA, sB]);
|
|
1874
|
+
const d201 = await collectRun("r201", "201", [sA]);
|
|
1875
|
+
const d202 = await collectRun("r202", "202", [sA, sB, sC, sD]);
|
|
1876
|
+
const firstParent = gitFx("rev-list", "--first-parent", "HEAD").split("\n");
|
|
1877
|
+
const independent = currentCandidate(origin, set, sA.gate);
|
|
1878
|
+
const r100 = asRun(d100, x1, "100");
|
|
1879
|
+
const r101 = asRun(d101, x1, "101");
|
|
1880
|
+
const r200 = asRun(d200, x2, "200");
|
|
1881
|
+
const r201 = asRun(d201, x2, "201");
|
|
1882
|
+
const r202 = asRun(d202, x2, "202");
|
|
1883
|
+
const release = (runs: CiRun[], over: Partial<ReleaseInput> = {}) =>
|
|
1884
|
+
composeRelease({ finalSha: x2, firstParent, runs, independent, operatorDeclared: [], ...over });
|
|
1885
|
+
const groupOf = (l: ReturnType<typeof composeRelease>, s: MutantSpec) =>
|
|
1886
|
+
l.groups.find((g) => JSON.stringify(g.gate) === JSON.stringify(s.gate));
|
|
1887
|
+
const verdicts: string[] = [];
|
|
1888
|
+
const seen = (l: ReturnType<typeof composeRelease>) => {
|
|
1889
|
+
verdicts.push(l.verdict);
|
|
1890
|
+
return l;
|
|
1891
|
+
};
|
|
1892
|
+
|
|
1893
|
+
// 680-shaped: red at X1, fixed and re-measured at X2; C and D only inherited.
|
|
1894
|
+
const good = seen(release([r100, r200]));
|
|
1895
|
+
ok(
|
|
1896
|
+
"[QK:RELEASE-RED-REMEASURED-CLEARS] release 680: ancestor red re-measured green on the final SHA → FAIL 0, red kept in history, C/D c-core → RISK-PENDING",
|
|
1897
|
+
good.verdict === "RISK-PENDING" &&
|
|
1898
|
+
good.counts.groups.FAIL === 0 &&
|
|
1899
|
+
groupOf(good, sB)?.state === "MEASURED" &&
|
|
1900
|
+
groupOf(good, sB)?.provenance === "exact" &&
|
|
1901
|
+
(groupOf(good, sB)?.history.some((h) => !h.passed) ?? false) &&
|
|
1902
|
+
groupOf(good, sC)?.inheritKind === "c-core" &&
|
|
1903
|
+
good.release.required.length === 2 &&
|
|
1904
|
+
good.release.denominator.measuredOnFinal.groups === 2 &&
|
|
1905
|
+
good.release.denominator.inheritedCore.every((i) => i.from?.runId === "100") &&
|
|
1906
|
+
good.release.denominator.groups === 4,
|
|
1907
|
+
);
|
|
1908
|
+
const notRemeasured = seen(release([r100, r201]));
|
|
1909
|
+
ok(
|
|
1910
|
+
"release: the newest red (X1) NOT re-measured on the final SHA → required-not-measured, NOT-QUALIFIED",
|
|
1911
|
+
notRemeasured.verdict === "NOT-QUALIFIED" &&
|
|
1912
|
+
notRemeasured.blockers.some((b) => b.startsWith("required-not-measured: bash gates/late.sh")),
|
|
1913
|
+
);
|
|
1914
|
+
const skipped = seen(release([r101, r200]));
|
|
1915
|
+
ok(
|
|
1916
|
+
"[QK:RELEASE-UNRUN-BLOCKS] release: a group with no pass receipt anywhere in the chain (D skipped) → NOT-QUALIFIED",
|
|
1917
|
+
skipped.verdict === "NOT-QUALIFIED" &&
|
|
1918
|
+
skipped.blockers.some((b) => b.startsWith("unrun (release): bash gates/tail.sh d")),
|
|
1919
|
+
);
|
|
1920
|
+
const specNow = "0".repeat(64);
|
|
1921
|
+
const specDrift = seen(
|
|
1922
|
+
release([r100, editCollection(r200, (c) => (c.candidate.groups[2].specSetSha256 = specNow))], {
|
|
1923
|
+
independent: {
|
|
1924
|
+
...independent,
|
|
1925
|
+
groups: independent.groups.map((g, i) => (i === 2 ? { ...g, specSetSha256: specNow } : g)),
|
|
1926
|
+
},
|
|
1927
|
+
}),
|
|
1928
|
+
);
|
|
1929
|
+
ok(
|
|
1930
|
+
"release: C's spec set changed since its only pass → C UNRUN, NOT-QUALIFIED (re-measure)",
|
|
1931
|
+
specDrift.verdict === "NOT-QUALIFIED" && groupOf(specDrift, sC)?.state === "UNRUN",
|
|
1932
|
+
);
|
|
1933
|
+
const dupAttempt = seen(release([r100, r200, r200]));
|
|
1934
|
+
const r200b = editCollection({ ...r200, runAttempt: "2" }, (c) => {
|
|
1935
|
+
c.runAttempt = "2";
|
|
1936
|
+
});
|
|
1937
|
+
// Both input orders: the newer attempt must win by (runId, runAttempt), not by position.
|
|
1938
|
+
const retried = seen(release([r100, r200b, r200]));
|
|
1939
|
+
const retriedRev = seen(release([r100, r200, r200b]));
|
|
1940
|
+
ok(
|
|
1941
|
+
"[QK:RELEASE-NEWEST-ATTEMPT-WINS] release: the same run attempt twice → RED; a newer runAttempt supersedes the older in either input order, which is listed, never mixed",
|
|
1942
|
+
dupAttempt.verdict === "RED" &&
|
|
1943
|
+
dupAttempt.release.contractErrors.some((e) => e.includes("supplied twice")) &&
|
|
1944
|
+
[retried, retriedRev].every(
|
|
1945
|
+
(l) =>
|
|
1946
|
+
l.verdict === "RISK-PENDING" &&
|
|
1947
|
+
l.release.used.some((u) => u.runId === "200" && u.runAttempt === "2") &&
|
|
1948
|
+
l.release.excluded.some(
|
|
1949
|
+
(e) => e.runId === "200" && e.runAttempt === "1" && e.reason.startsWith("superseded"),
|
|
1950
|
+
),
|
|
1951
|
+
),
|
|
1952
|
+
);
|
|
1953
|
+
const noFinal = seen(release([r100]));
|
|
1954
|
+
const incompleteFinal = seen(
|
|
1955
|
+
release([
|
|
1956
|
+
r100,
|
|
1957
|
+
{ ...r200, files: Object.fromEntries(Object.entries(r200.files).filter(([f]) => f !== "g001.json")) },
|
|
1958
|
+
]),
|
|
1959
|
+
);
|
|
1960
|
+
const incompleteAncestor = seen(
|
|
1961
|
+
release([
|
|
1962
|
+
{ ...r100, files: Object.fromEntries(Object.entries(r100.files).filter(([f]) => f !== "g003.json")) },
|
|
1963
|
+
r200,
|
|
1964
|
+
]),
|
|
1965
|
+
);
|
|
1966
|
+
ok(
|
|
1967
|
+
"[QK:RELEASE-FINAL-COLLECTION-REQUIRED] release: no final-SHA collection → RED; an incomplete final collection → RED",
|
|
1968
|
+
noFinal.verdict === "RED" &&
|
|
1969
|
+
noFinal.release.contractErrors.some((e) => e.startsWith("no CI collection at the final SHA")) &&
|
|
1970
|
+
incompleteFinal.verdict === "RED" &&
|
|
1971
|
+
incompleteFinal.release.contractErrors.some((e) => e.startsWith("final-SHA collection incomplete")),
|
|
1972
|
+
);
|
|
1973
|
+
ok(
|
|
1974
|
+
"release: an incomplete ANCESTOR collection is excluded from the basis with its reason (its groups fall to UNRUN)",
|
|
1975
|
+
incompleteAncestor.verdict === "NOT-QUALIFIED" &&
|
|
1976
|
+
incompleteAncestor.release.excluded.some(
|
|
1977
|
+
(e) => e.runId === "100" && e.reason.startsWith("incomplete ancestor"),
|
|
1978
|
+
),
|
|
1979
|
+
);
|
|
1980
|
+
const offChain = seen(release([r100, r200, { ...r201, headSha: "f".repeat(40) }]));
|
|
1981
|
+
const local = seen(release([r100, { ...r200, runId: "local" }]));
|
|
1982
|
+
const lies = seen(release([r100, editCollection(r200, (c) => (c.runId = "999"))]));
|
|
1983
|
+
ok(
|
|
1984
|
+
"[QK:RELEASE-INPUT-CONTRACT] release: a run off the first-parent chain, a non-CI run id, a collection naming another run → each RED",
|
|
1985
|
+
offChain.verdict === "RED" &&
|
|
1986
|
+
offChain.release.contractErrors.some((e) => e.includes("not on the first-parent chain")) &&
|
|
1987
|
+
local.verdict === "RED" &&
|
|
1988
|
+
local.release.contractErrors.some((e) => e.includes("not CI provenance")) &&
|
|
1989
|
+
lies.verdict === "RED" &&
|
|
1990
|
+
lies.release.contractErrors.some((e) => e.includes("the collection records run 999")),
|
|
1991
|
+
);
|
|
1992
|
+
const crossed = seen(release([r100, r200], { independent: { ...independent, workSurfaceSha256: "0".repeat(64) } }));
|
|
1993
|
+
const coreFileCrossed = seen(
|
|
1994
|
+
release([r100, r200], {
|
|
1995
|
+
independent: { ...independent, coreFiles: { ...independent.coreFiles, "run.sh": "0".repeat(64) } },
|
|
1996
|
+
}),
|
|
1997
|
+
);
|
|
1998
|
+
ok(
|
|
1999
|
+
"[QK:RELEASE-CANDIDATE-CROSS-CHECKED] release: a CI-claimed surface or CORE_FILES the final tree contradicts → RED CROSS-CHECK",
|
|
2000
|
+
crossed.verdict === "RED" &&
|
|
2001
|
+
crossed.release.crossCheck.includes("workSurface") &&
|
|
2002
|
+
coreFileCrossed.verdict === "RED" &&
|
|
2003
|
+
coreFileCrossed.release.crossCheck.includes("coreFiles"),
|
|
2004
|
+
);
|
|
2005
|
+
// Environment drift: the ancestor run's runner had another node/node_modules. Rewrite
|
|
2006
|
+
// its core witness consistently (receipts resealed, digests re-declared) — the
|
|
2007
|
+
// ancestor stays a valid basis, and its groups inherit only as c-core with `core` named.
|
|
2008
|
+
const otherCore = "e".repeat(64);
|
|
2009
|
+
const rewriteCore = (run: CiRun): CiRun => {
|
|
2010
|
+
const c = JSON.parse(run.files["collection.json"].toString("utf8"));
|
|
2011
|
+
c.candidate.coreSha256 = otherCore;
|
|
2012
|
+
const files: Record<string, Buffer> = {};
|
|
2013
|
+
for (const a of c.attempted) {
|
|
2014
|
+
const { receiptSha256: _d, ...body } = JSON.parse(run.files[a.file].toString("utf8"));
|
|
2015
|
+
body.core.sha256 = otherCore;
|
|
2016
|
+
const text = Buffer.from(JSON.stringify(sealReceipt(body)));
|
|
2017
|
+
files[a.file] = text;
|
|
2018
|
+
a.sha256 = createHash("sha256").update(text).digest("hex");
|
|
2019
|
+
}
|
|
2020
|
+
files["collection.json"] = Buffer.from(JSON.stringify(c));
|
|
2021
|
+
return { ...run, files };
|
|
2022
|
+
};
|
|
2023
|
+
const envDrift = seen(release([rewriteCore(r100), r200]));
|
|
2024
|
+
ok(
|
|
2025
|
+
"release: an ancestor from a different runner environment → its groups c-core with `core` in the reason; RISK-PENDING, never green",
|
|
2026
|
+
envDrift.verdict === "RISK-PENDING" &&
|
|
2027
|
+
envDrift.release.denominator.inheritedCore.length === 2 &&
|
|
2028
|
+
envDrift.release.denominator.inheritedCore.every((i) => i.reason.includes("core")),
|
|
2029
|
+
);
|
|
2030
|
+
// F1 (#124 review): the pure composer must not invent a clean candidate. Same runs, same
|
|
2031
|
+
// CI witness, same surface — only the independent candidate's porcelain differs.
|
|
2032
|
+
const greenAll = seen(release([r100, r202]));
|
|
2033
|
+
const dirtyGreen = seen(release([r100, r202], { independent: { ...independent, porcelainClean: false } }));
|
|
2034
|
+
ok(
|
|
2035
|
+
"[QK:RELEASE-DIRTY-NOT-INVENTED] release: every group exact-MEASURED is COMPOSITE-GREEN only on a clean candidate; the same evidence on a dirty one → NOT-QUALIFIED candidate-dirty",
|
|
2036
|
+
greenAll.verdict === "COMPOSITE-GREEN" &&
|
|
2037
|
+
dirtyGreen.verdict === "NOT-QUALIFIED" &&
|
|
2038
|
+
dirtyGreen.blockers.some((b) => b.startsWith("candidate-dirty")),
|
|
2039
|
+
);
|
|
2040
|
+
// F2 (#124 review): a COMPLETE collection is an inventory. Its red middle group makes the
|
|
2041
|
+
// composition RED, and the tail measured after that failure is kept as evidence.
|
|
2042
|
+
const atX1 = seen(
|
|
2043
|
+
composeRelease({
|
|
2044
|
+
finalSha: x1,
|
|
2045
|
+
firstParent: firstParentX1,
|
|
2046
|
+
runs: [r100],
|
|
2047
|
+
independent: independentX1,
|
|
2048
|
+
operatorDeclared: [],
|
|
2049
|
+
}),
|
|
2050
|
+
);
|
|
2051
|
+
ok(
|
|
2052
|
+
"[QK:RELEASE-COMPLETE-RED-IS-RED] release: a COMPLETE collection with a red middle group and green tail → RED, B FAIL, C/D MEASURED — collection completion is never a verdict",
|
|
2053
|
+
checkCollectionData(mapReader(r100.files), Object.keys(r100.files).sort()).errors.length === 0 &&
|
|
2054
|
+
atX1.verdict === "RED" &&
|
|
2055
|
+
groupOf(atX1, sB)?.state === "FAIL" &&
|
|
2056
|
+
groupOf(atX1, sC)?.state === "MEASURED" &&
|
|
2057
|
+
groupOf(atX1, sD)?.state === "MEASURED",
|
|
2058
|
+
);
|
|
2059
|
+
const declared = seen(release([r100, r200], { operatorDeclared: [sC.gate] }));
|
|
2060
|
+
ok(
|
|
2061
|
+
"release: an operator-declared group must be exact-MEASURED on the final SHA → NOT-QUALIFIED until it is",
|
|
2062
|
+
declared.verdict === "NOT-QUALIFIED" &&
|
|
2063
|
+
declared.blockers.some((b) => b.startsWith("required-not-measured: bash gates/tail.sh")),
|
|
2064
|
+
);
|
|
2065
|
+
|
|
2066
|
+
// ── the acceptance RECORD over these real ledgers (#124 P2) ──
|
|
2067
|
+
// A consistent record is a bookkeeping fact about bytes, never an acceptance: the
|
|
2068
|
+
// positive is `good` (real X1→X2 receipts); each negative isolates ONE guard.
|
|
2069
|
+
type RL = ReturnType<typeof composeRelease>;
|
|
2070
|
+
const recordFor = (l: RL, over: Record<string, unknown> = {}) => ({
|
|
2071
|
+
schema: ACCEPTANCE_SCHEMA,
|
|
2072
|
+
finalSha: l.release.finalSha,
|
|
2073
|
+
ledgerSha256: sha256(canonicalJson(l)),
|
|
2074
|
+
risks: [...l.risks],
|
|
2075
|
+
required: l.release.required,
|
|
2076
|
+
utterance: "C/D c-core 위험을 알고 받아들인다",
|
|
2077
|
+
acceptedOn: "2026-09-29",
|
|
2078
|
+
transcribedBy: "claudecode/opus (test fixture)",
|
|
2079
|
+
...over,
|
|
2080
|
+
});
|
|
2081
|
+
const accepted = checkAcceptanceRecord(good, recordFor(good));
|
|
2082
|
+
ok(
|
|
2083
|
+
"acceptance: a record naming the real RISK-PENDING ledger exactly → RECORD-CONSISTENT (statement not authenticated), never an acceptance class",
|
|
2084
|
+
accepted.outcome === RECORD_CONSISTENT &&
|
|
2085
|
+
accepted.errors.length === 0 &&
|
|
2086
|
+
good.risks.length >= 2 &&
|
|
2087
|
+
good.candidate.head === x2 &&
|
|
2088
|
+
!String(accepted.outcome).includes("ACCEPTED") &&
|
|
2089
|
+
!String(accepted.outcome).includes("GREEN"),
|
|
2090
|
+
);
|
|
2091
|
+
const refused = (l: RL, rec: unknown, naming: string) => {
|
|
2092
|
+
const r = checkAcceptanceRecord(l, rec);
|
|
2093
|
+
return r.outcome === "REFUSED" && r.errors.some((e) => e.includes(naming));
|
|
2094
|
+
};
|
|
2095
|
+
const onlyVerdict: RL = { ...good, verdict: "NOT-QUALIFIED" };
|
|
2096
|
+
ok(
|
|
2097
|
+
"[QK:ACCEPT-ONLY-RISK-PENDING] acceptance: the real ledger with ONLY its verdict set to NOT-QUALIFIED (digest consistent) → REFUSED; real RED and real COMPOSITE-GREEN → REFUSED",
|
|
2098
|
+
refused(onlyVerdict, recordFor(onlyVerdict), "not RISK-PENDING") &&
|
|
2099
|
+
checkAcceptanceRecord(onlyVerdict, recordFor(onlyVerdict)).errors.length === 1 &&
|
|
2100
|
+
refused(noFinal, recordFor(noFinal), "not RISK-PENDING") &&
|
|
2101
|
+
refused(greenAll, recordFor(greenAll), "not RISK-PENDING") &&
|
|
2102
|
+
refused(notRemeasured, recordFor(notRemeasured), "not RISK-PENDING"),
|
|
2103
|
+
);
|
|
2104
|
+
const cIdx = good.groups.findIndex((g) => JSON.stringify(g.gate) === JSON.stringify(sC.gate));
|
|
2105
|
+
const cRisk = good.risks.findIndex((r) => r.startsWith(`c-core: ${sC.gate.join(" ")} `));
|
|
2106
|
+
const forgedUnrun: RL = {
|
|
2107
|
+
...good,
|
|
2108
|
+
groups: good.groups.map((g, i) =>
|
|
2109
|
+
i === cIdx ? { ...g, state: "UNRUN" as const, inheritKind: null, reasons: [] } : g,
|
|
2110
|
+
),
|
|
2111
|
+
risks: good.risks.map((r, i) =>
|
|
2112
|
+
i === cRisk ? `unrun: ${sC.gate.join(" ")} (${good.groups[cIdx].claims.length} claims)` : r,
|
|
2113
|
+
),
|
|
2114
|
+
};
|
|
2115
|
+
const forgedDirty: RL = { ...good, candidate: { ...good.candidate, porcelainClean: false } };
|
|
2116
|
+
ok(
|
|
2117
|
+
"[QK:ACCEPT-REFUSAL-REDERIVED] acceptance: a RISK-PENDING ledger with its blockers stripped — an UNRUN group, or a dirty candidate (risks/digest/record consistent) → REFUSED from its own groups and candidate",
|
|
2118
|
+
cIdx >= 0 &&
|
|
2119
|
+
cRisk >= 0 &&
|
|
2120
|
+
refused(forgedUnrun, recordFor(forgedUnrun), "UNRUN") &&
|
|
2121
|
+
checkAcceptanceRecord(forgedUnrun, recordFor(forgedUnrun)).errors.length === 1 &&
|
|
2122
|
+
refused(forgedDirty, recordFor(forgedDirty), "dirty") &&
|
|
2123
|
+
checkAcceptanceRecord(forgedDirty, recordFor(forgedDirty)).errors.length === 1,
|
|
2124
|
+
);
|
|
2125
|
+
const moreSummary: RL = { ...good, summary: [...good.summary, " a line added after the record was written"] };
|
|
2126
|
+
ok(
|
|
2127
|
+
"[QK:ACCEPT-DIGEST-RECOMPUTED] acceptance: one summary line added to the ledger after the record named it → REFUSED on the recomputed digest alone",
|
|
2128
|
+
refused(moreSummary, recordFor(good), "recomputed digest") &&
|
|
2129
|
+
checkAcceptanceRecord(moreSummary, recordFor(good)).errors.length === 1,
|
|
2130
|
+
);
|
|
2131
|
+
const reordered = recordFor(good, { risks: [...good.risks].reverse() });
|
|
2132
|
+
ok(
|
|
2133
|
+
"[QK:ACCEPT-RISKS-EXACT] acceptance: the ledger's risks in another order → REFUSED (order is part of what was accepted)",
|
|
2134
|
+
good.risks[0] !== good.risks[good.risks.length - 1] &&
|
|
2135
|
+
refused(good, reordered, "order and duplicates") &&
|
|
2136
|
+
checkAcceptanceRecord(good, reordered).errors.length === 1,
|
|
2137
|
+
);
|
|
2138
|
+
const dropped: RL = { ...good, risks: good.risks.slice(1) };
|
|
2139
|
+
ok(
|
|
2140
|
+
"[QK:ACCEPT-RISKS-REDERIVED] acceptance: a ledger whose risk list drops one its own groups imply (record and digest consistent with it) → REFUSED",
|
|
2141
|
+
refused(dropped, recordFor(dropped), "its own groups imply") &&
|
|
2142
|
+
checkAcceptanceRecord(dropped, recordFor(dropped)).errors.length === 1,
|
|
2143
|
+
);
|
|
2144
|
+
ok(
|
|
2145
|
+
"[QK:ACCEPT-DATE-REAL] acceptance: a date-shaped but unreal acceptedOn (2026-02-30) → REFUSED",
|
|
2146
|
+
refused(good, recordFor(good, { acceptedOn: "2026-02-30" }), "not a real date") &&
|
|
2147
|
+
checkAcceptanceRecord(good, recordFor(good, { acceptedOn: "2026-02-28" })).outcome === RECORD_CONSISTENT,
|
|
2148
|
+
);
|
|
2149
|
+
ok(
|
|
2150
|
+
"acceptance: an authentication-shaped key, a missing key, another final SHA, other required groups, a blank utterance, a bad date shape → each REFUSED",
|
|
2151
|
+
refused(good, { ...recordFor(good), authenticated: true }, "keys must be exactly") &&
|
|
2152
|
+
refused(good, (({ transcribedBy: _t, ...rest }) => rest)(recordFor(good)), "keys must be exactly") &&
|
|
2153
|
+
refused(good, recordFor(good, { finalSha: x1 }), "final SHA") &&
|
|
2154
|
+
refused(good, recordFor(good, { required: [sA.gate] }), "required groups") &&
|
|
2155
|
+
refused(good, recordFor(good, { utterance: " " }), "utterance") &&
|
|
2156
|
+
refused(good, recordFor(good, { acceptedOn: "2026-9-29" }), "YYYY-MM-DD") &&
|
|
2157
|
+
refused(good, null, "not an object"),
|
|
2158
|
+
);
|
|
2159
|
+
ok(
|
|
2160
|
+
"release: no composition produced an acceptance class — only RED / NOT-QUALIFIED / RISK-PENDING / COMPOSITE-GREEN",
|
|
2161
|
+
verdicts.every((v) => ["RED", "NOT-QUALIFIED", "RISK-PENDING", "COMPOSITE-GREEN"].includes(v)),
|
|
2162
|
+
);
|
|
2163
|
+
} finally {
|
|
2164
|
+
fs.rmSync(origin, { recursive: true, force: true });
|
|
2165
|
+
fs.rmSync(path.dirname(externalTarget), { recursive: true, force: true });
|
|
2166
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
// ═══ Phase 1c-a — admitting ONE CI collection artifact, offline (#124 stage 3a) ═
|
|
2171
|
+
//
|
|
2172
|
+
// A real fixture collection, zipped by the host's own Python zipfile (fixture only — the
|
|
2173
|
+
// module reads zips with Node's built-in zlib), fed with injected GitHub-shaped artifact
|
|
2174
|
+
// metadata. Every refusal is a named error; nothing is extracted to disk.
|
|
2175
|
+
|
|
2176
|
+
{
|
|
2177
|
+
const tmpRoot = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-artifact-selftest-")));
|
|
2178
|
+
const { dir: origin, externalTarget } = buildFixtureOrigin();
|
|
2179
|
+
try {
|
|
2180
|
+
const sA = fixtureSpec({});
|
|
2181
|
+
const collDir = path.join(tmpRoot, "r300");
|
|
2182
|
+
await collectGroups({
|
|
2183
|
+
repoDir: origin,
|
|
2184
|
+
selected: [sA],
|
|
2185
|
+
requested: [sA.gate],
|
|
2186
|
+
collectDir: collDir,
|
|
2187
|
+
log: quiet,
|
|
2188
|
+
logError: quiet,
|
|
2189
|
+
tmpRoot,
|
|
2190
|
+
coreGroupGate: sA.gate,
|
|
2191
|
+
env: { GITHUB_RUN_ID: "300", GITHUB_RUN_ATTEMPT: "1" },
|
|
2192
|
+
});
|
|
2193
|
+
const head = spawnSync("git", ["-C", origin, "rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
|
2194
|
+
const good: Record<string, Buffer> = Object.fromEntries(
|
|
2195
|
+
fs.readdirSync(collDir).map((f) => [f, fs.readFileSync(path.join(collDir, f))]),
|
|
2196
|
+
);
|
|
2197
|
+
type Entry = { name: string; data: Buffer; kind?: "symlink" | "dir" };
|
|
2198
|
+
// Zip with the host's Python zipfile: names, symlinks, directories and duplicates exactly as given.
|
|
2199
|
+
const zipOf = (entries: Entry[]): Buffer => {
|
|
2200
|
+
const spec = entries.map((e) => ({ name: e.name, data: e.data.toString("base64"), kind: e.kind ?? "file" }));
|
|
2201
|
+
const r = spawnSync(
|
|
2202
|
+
"python3",
|
|
2203
|
+
[
|
|
2204
|
+
"-c",
|
|
2205
|
+
[
|
|
2206
|
+
"import sys, json, base64, io, zipfile, warnings",
|
|
2207
|
+
"warnings.simplefilter('ignore')",
|
|
2208
|
+
"out = io.BytesIO()",
|
|
2209
|
+
"with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:",
|
|
2210
|
+
" for e in json.load(sys.stdin):",
|
|
2211
|
+
" i = zipfile.ZipInfo(e['name']); i.create_system = 3",
|
|
2212
|
+
" i.external_attr = {'symlink': 0o120777, 'dir': 0o040755}.get(e['kind'], 0o100644) << 16",
|
|
2213
|
+
" i.compress_type = zipfile.ZIP_DEFLATED",
|
|
2214
|
+
" z.writestr(i, base64.b64decode(e['data']))",
|
|
2215
|
+
"sys.stdout.write(base64.b64encode(out.getvalue()).decode())",
|
|
2216
|
+
].join("\n"),
|
|
2217
|
+
],
|
|
2218
|
+
{ input: JSON.stringify(spec), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
|
2219
|
+
);
|
|
2220
|
+
assert.equal(r.status, 0, `fixture zip failed: ${r.stderr}`);
|
|
2221
|
+
return Buffer.from(r.stdout, "base64");
|
|
2222
|
+
};
|
|
2223
|
+
const entriesOf = (files: Record<string, Buffer>): Entry[] =>
|
|
2224
|
+
Object.entries(files).map(([name, data]) => ({ name, data }));
|
|
2225
|
+
const meta = (zip: Buffer, over: Partial<ApiArtifact> = {}): ApiArtifact => ({
|
|
2226
|
+
id: 1,
|
|
2227
|
+
name: artifactName("300", "1"),
|
|
2228
|
+
digest: `sha256:${createHash("sha256").update(zip).digest("hex")}`,
|
|
2229
|
+
expired: false,
|
|
2230
|
+
workflow_run: { id: 300, head_sha: head },
|
|
2231
|
+
...over,
|
|
2232
|
+
});
|
|
2233
|
+
const admit = (zip: Buffer, over: Partial<ArtifactInput> = {}, metaOver: Partial<ApiArtifact> = {}) =>
|
|
2234
|
+
admitArtifact({ runId: "300", runAttempt: "1", headSha: head, artifact: meta(zip, metaOver), zip, ...over });
|
|
2235
|
+
const goodZip = zipOf(entriesOf(good));
|
|
2236
|
+
const cur = currentCandidate(origin, [sA], sA.gate);
|
|
2237
|
+
|
|
2238
|
+
const ok0 = admit(goodZip, { independentCoreFiles: cur.coreFiles });
|
|
2239
|
+
ok(
|
|
2240
|
+
"artifact: a real collection zipped, its digest recorded → admitted with no error; node/node_modules labelled a CI witness",
|
|
2241
|
+
ok0.errors.length === 0 && ok0.collection?.runId === "300" && ok0.witness.includes("CI-witnessed"),
|
|
2242
|
+
);
|
|
2243
|
+
const sel0 = selectArtifact([], "300", "1");
|
|
2244
|
+
const sel2 = selectArtifact([meta(goodZip), meta(goodZip, { id: 2 })], "300", "1");
|
|
2245
|
+
ok(
|
|
2246
|
+
"[QK:ARTIFACT-SELECT-EXACTLY-ONE] artifact: zero or two artifacts with the run's name are each refused, never picked from",
|
|
2247
|
+
!sel0.ok &&
|
|
2248
|
+
sel0.errors[0].startsWith("artifact-select: 0") &&
|
|
2249
|
+
!sel2.ok &&
|
|
2250
|
+
sel2.errors[0].startsWith("artifact-select: 2"),
|
|
2251
|
+
);
|
|
2252
|
+
const digestBad = admit(goodZip, {}, { digest: `sha256:${"0".repeat(64)}` });
|
|
2253
|
+
const expired = admit(goodZip, {}, { expired: true });
|
|
2254
|
+
const otherRun = admit(goodZip, {}, { workflow_run: { id: 301, head_sha: head } });
|
|
2255
|
+
const otherHead = admit(goodZip, {}, { workflow_run: { id: 300, head_sha: "f".repeat(40) } });
|
|
2256
|
+
ok(
|
|
2257
|
+
"[QK:ARTIFACT-DIGEST-CHECKED] artifact: a digest that is not the zip's sha256, an expired artifact, another run, another SHA → each named",
|
|
2258
|
+
digestBad.errors.some((e) => e.startsWith("artifact-digest:")) &&
|
|
2259
|
+
expired.errors.includes("artifact-expired: the evidence is gone") &&
|
|
2260
|
+
otherRun.errors.some((e) => e.startsWith("artifact-run:")) &&
|
|
2261
|
+
otherHead.errors.some((e) => e.startsWith("artifact-head:")) &&
|
|
2262
|
+
[digestBad, expired, otherRun, otherHead].every((r) => r.files === null),
|
|
2263
|
+
);
|
|
2264
|
+
const withEntry = (extra: Entry) => admit(zipOf([...entriesOf(good), extra]));
|
|
2265
|
+
const dotdot = withEntry({ name: "../g001.json", data: good["g000.json"] });
|
|
2266
|
+
const absolute = withEntry({ name: "/g001.json", data: good["g000.json"] });
|
|
2267
|
+
const link = withEntry({ name: "g001.json", data: Buffer.from("/etc/passwd"), kind: "symlink" });
|
|
2268
|
+
const dir = withEntry({ name: "g001.json/", data: Buffer.alloc(0), kind: "dir" });
|
|
2269
|
+
const dup = withEntry({ name: "g000.json", data: good["g000.json"] });
|
|
2270
|
+
const stray = withEntry({ name: "notes.txt", data: Buffer.from("x") });
|
|
2271
|
+
const big = withEntry({ name: "g001.json", data: Buffer.alloc(9 * 1024 * 1024) });
|
|
2272
|
+
ok(
|
|
2273
|
+
"[QK:ARTIFACT-ZIP-CONFINED] artifact: `../`, absolute, symlink, directory, duplicate, foreign-named and oversize entries → each a named zip error",
|
|
2274
|
+
dotdot.errors.some((e) => e.startsWith('zip-name: "../g001.json"')) &&
|
|
2275
|
+
absolute.errors.some((e) => e.startsWith('zip-name: "/g001.json"')) &&
|
|
2276
|
+
link.errors.includes('zip-symlink: "g001.json"') &&
|
|
2277
|
+
dir.errors.includes('zip-directory: "g001.json/"') &&
|
|
2278
|
+
dup.errors.includes('zip-duplicate: "g000.json"') &&
|
|
2279
|
+
stray.errors.some((e) => e.startsWith('zip-name: "notes.txt"')) &&
|
|
2280
|
+
big.errors.some((e) => e.startsWith('zip-oversize: "g001.json"')) &&
|
|
2281
|
+
[dotdot, absolute, link, dir, dup, stray, big].every((r) => r.files === null),
|
|
2282
|
+
);
|
|
2283
|
+
// Structure: a byte-level mutator over a real zip (host Python, fixture only). Each
|
|
2284
|
+
// mutated archive gets its digest re-declared, so only the reader can refuse it.
|
|
2285
|
+
const mutateZip = (zip: Buffer, op: string, arg = 0): Buffer => {
|
|
2286
|
+
const r = spawnSync(
|
|
2287
|
+
"python3",
|
|
2288
|
+
[
|
|
2289
|
+
"-c",
|
|
2290
|
+
[
|
|
2291
|
+
"import sys, base64, struct",
|
|
2292
|
+
"b = bytearray(base64.b64decode(sys.stdin.read())); op = sys.argv[1]; arg = int(sys.argv[2])",
|
|
2293
|
+
"e = b.rfind(b'PK\\x05\\x06')",
|
|
2294
|
+
"if op == 'count': struct.pack_into('<HH', b, e + 8, arg, arg)",
|
|
2295
|
+
"elif op == 'trailing': b += b'X' * arg",
|
|
2296
|
+
"elif op == 'comment': struct.pack_into('<H', b, e + 20, arg)",
|
|
2297
|
+
"elif op == 'disk': struct.pack_into('<H', b, e + 4, arg)",
|
|
2298
|
+
"elif op == 'drop-first-central':",
|
|
2299
|
+
" n, size, off = struct.unpack_from('<HII', b, e + 10)",
|
|
2300
|
+
" nl, xl, cl = struct.unpack_from('<HHH', b, off + 28); rec = 46 + nl + xl + cl",
|
|
2301
|
+
" del b[off:off + rec]; e -= rec",
|
|
2302
|
+
" struct.pack_into('<HHII', b, e + 8, n - 1, n - 1, size - rec, off)",
|
|
2303
|
+
"sys.stdout.write(base64.b64encode(bytes(b)).decode())",
|
|
2304
|
+
].join("\n"),
|
|
2305
|
+
op,
|
|
2306
|
+
String(arg),
|
|
2307
|
+
],
|
|
2308
|
+
{ input: zip.toString("base64"), encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
|
|
2309
|
+
);
|
|
2310
|
+
assert.equal(r.status, 0, `fixture zip mutation ${op} failed: ${r.stderr}`);
|
|
2311
|
+
return Buffer.from(r.stdout, "base64");
|
|
2312
|
+
};
|
|
2313
|
+
const withStray = zipOf([...entriesOf(good), { name: "notes.txt", data: Buffer.from("stray") }]);
|
|
2314
|
+
const strayFirst = zipOf([{ name: "notes.txt", data: Buffer.from("stray") }, ...entriesOf(good)]);
|
|
2315
|
+
const lowCount = admit(mutateZip(withStray, "count", Object.keys(good).length));
|
|
2316
|
+
const trailing = admit(mutateZip(goodZip, "trailing", 7));
|
|
2317
|
+
const commentLie = admit(mutateZip(goodZip, "comment", 5));
|
|
2318
|
+
const multiDisk = admit(mutateZip(goodZip, "disk", 1));
|
|
2319
|
+
const hiddenLocal = admit(mutateZip(strayFirst, "drop-first-central"));
|
|
2320
|
+
const huge = admit(Buffer.alloc(81 * 1024 * 1024));
|
|
2321
|
+
const fmt = (r: ReturnType<typeof admit>, text: string) =>
|
|
2322
|
+
r.files === null && r.errors.length === 1 && r.errors[0].startsWith("zip-") && r.errors[0].includes(text);
|
|
2323
|
+
ok(
|
|
2324
|
+
"[QK:ARTIFACT-ZIP-STRUCTURE] artifact: a lowered EOCD entry count hiding a listed entry, trailing bytes, a lying comment length, a multi-disk EOCD, an unlisted local entry, an 81MiB archive → each refused by structure before any content",
|
|
2325
|
+
fmt(lowCount, "not covered by its entry count") &&
|
|
2326
|
+
fmt(trailing, "does not end the archive") &&
|
|
2327
|
+
fmt(commentLie, "does not end the archive") &&
|
|
2328
|
+
fmt(multiDisk, "multi-disk") &&
|
|
2329
|
+
fmt(hiddenLocal, "unlisted bytes") &&
|
|
2330
|
+
fmt(huge, "zip-oversize: archive"),
|
|
2331
|
+
);
|
|
2332
|
+
// Independent observation only (not a safety proof): the host's Python zipfile lists
|
|
2333
|
+
// the same names our reader admits for the well-formed archive.
|
|
2334
|
+
const pyNames = spawnSync(
|
|
2335
|
+
"python3",
|
|
2336
|
+
[
|
|
2337
|
+
"-c",
|
|
2338
|
+
"import sys, io, zipfile, base64, json; print(json.dumps(sorted(zipfile.ZipFile(io.BytesIO(base64.b64decode(sys.stdin.read()))).namelist()), separators=(',', ':')))",
|
|
2339
|
+
],
|
|
2340
|
+
{ input: goodZip.toString("base64"), encoding: "utf8" },
|
|
2341
|
+
);
|
|
2342
|
+
ok(
|
|
2343
|
+
"artifact: Python zipfile (observation, not proof) lists the same names our reader admits for the real collection zip",
|
|
2344
|
+
pyNames.status === 0 && pyNames.stdout.trim() === JSON.stringify(Object.keys(ok0.files ?? {}).sort()),
|
|
2345
|
+
);
|
|
2346
|
+
const coreWithout = (patch: (core: Record<string, unknown>) => void) => {
|
|
2347
|
+
const { receiptSha256: _d, ...body } = JSON.parse(good["g000.json"].toString("utf8"));
|
|
2348
|
+
patch(body.core);
|
|
2349
|
+
const text = Buffer.from(JSON.stringify(sealReceipt(body)));
|
|
2350
|
+
const c = JSON.parse(good["collection.json"].toString("utf8"));
|
|
2351
|
+
c.attempted[0].sha256 = createHash("sha256").update(text).digest("hex");
|
|
2352
|
+
return admit(zipOf(entriesOf({ ...good, "g000.json": text, "collection.json": Buffer.from(JSON.stringify(c)) })));
|
|
2353
|
+
};
|
|
2354
|
+
const noNode = coreWithout((core) => {
|
|
2355
|
+
delete core.node;
|
|
2356
|
+
});
|
|
2357
|
+
const nmFields = coreWithout((core) => {
|
|
2358
|
+
delete (core.nodeModules as Record<string, unknown>).files;
|
|
2359
|
+
});
|
|
2360
|
+
ok(
|
|
2361
|
+
"[QK:ARTIFACT-CORE-SHAPE] artifact: a receipt whose core lacks node identity, or whose nodeModules lacks a field → core-shape, never a CI witness",
|
|
2362
|
+
noNode.errors.includes("core-shape: g000.json core.node") &&
|
|
2363
|
+
nmFields.errors.includes("core-shape: g000.json core.nodeModules") &&
|
|
2364
|
+
noNode.files === null &&
|
|
2365
|
+
nmFields.files === null,
|
|
2366
|
+
);
|
|
2367
|
+
const undeclared = withEntry({ name: "g007.json", data: good["g000.json"] });
|
|
2368
|
+
const missing = admit(zipOf(entriesOf(good).filter((e) => e.name !== "g000.json")));
|
|
2369
|
+
ok(
|
|
2370
|
+
"artifact: an allowed-looking but undeclared receipt, and a declared receipt missing from the zip → collection errors",
|
|
2371
|
+
undeclared.errors.includes("collection-extra: g007.json") &&
|
|
2372
|
+
missing.errors.some((e) => e.startsWith("collection-missing:")),
|
|
2373
|
+
);
|
|
2374
|
+
const attempt2 = admitArtifact({
|
|
2375
|
+
runId: "300",
|
|
2376
|
+
runAttempt: "2",
|
|
2377
|
+
headSha: head,
|
|
2378
|
+
artifact: { ...meta(goodZip), name: artifactName("300", "2") },
|
|
2379
|
+
zip: goodZip,
|
|
2380
|
+
});
|
|
2381
|
+
ok(
|
|
2382
|
+
"artifact: an artifact for attempt 2 whose collection records attempt 1 → collection-run mismatch",
|
|
2383
|
+
attempt2.errors.some((e) => e.startsWith("collection-run: records 300#1")),
|
|
2384
|
+
);
|
|
2385
|
+
// core.files edited, core.sha256 left as it was, receipt resealed and re-declared: only
|
|
2386
|
+
// recomputing the core from its own parts can see it.
|
|
2387
|
+
const tampered = (() => {
|
|
2388
|
+
const { receiptSha256: _d, ...body } = JSON.parse(good["g000.json"].toString("utf8"));
|
|
2389
|
+
body.core.files = { ...body.core.files, "run.sh": "0".repeat(64) };
|
|
2390
|
+
const text = Buffer.from(JSON.stringify(sealReceipt(body)));
|
|
2391
|
+
const c = JSON.parse(good["collection.json"].toString("utf8"));
|
|
2392
|
+
c.attempted[0].sha256 = createHash("sha256").update(text).digest("hex");
|
|
2393
|
+
return { ...good, "g000.json": text, "collection.json": Buffer.from(JSON.stringify(c)) };
|
|
2394
|
+
})();
|
|
2395
|
+
const coreBad = admit(zipOf(entriesOf(tampered)));
|
|
2396
|
+
const filesBad = admit(goodZip, {
|
|
2397
|
+
independentCoreFiles: { ...(cur.coreFiles ?? {}), "run.sh": "0".repeat(64) },
|
|
2398
|
+
});
|
|
2399
|
+
ok(
|
|
2400
|
+
"[QK:ARTIFACT-CORE-RECOMPUTED] artifact: core.sha256 not the hash of its own parts, or CORE_FILES unlike the final tree → named core errors",
|
|
2401
|
+
coreBad.errors.some((e) => e.startsWith("core-inconsistent: g000.json")) &&
|
|
2402
|
+
filesBad.errors.some((e) => e.startsWith("core-files-mismatch: g000.json")),
|
|
2403
|
+
);
|
|
2404
|
+
} finally {
|
|
2405
|
+
fs.rmSync(origin, { recursive: true, force: true });
|
|
2406
|
+
fs.rmSync(path.dirname(externalTarget), { recursive: true, force: true });
|
|
2407
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
// ═══ Phase 1c-h — the GitHub API boundary over injected fixtures (#124 stage 3b) ═
|
|
2412
|
+
//
|
|
2413
|
+
// No network: a GhApi built from a path→value map. The collection, its receipt and its
|
|
2414
|
+
// zip are real (production collectGroups + the host's Python zipfile); the run, job and
|
|
2415
|
+
// artifact metadata are GitHub-shaped fixtures. Real GitHub zip layout: UNOBSERVED.
|
|
2416
|
+
|
|
2417
|
+
{
|
|
2418
|
+
const tmpRoot = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-gh-selftest-")));
|
|
2419
|
+
const { dir: origin, externalTarget } = buildFixtureOrigin();
|
|
2420
|
+
try {
|
|
2421
|
+
const sA = fixtureSpec({});
|
|
2422
|
+
const repo = "owner/entwurf";
|
|
2423
|
+
const collDir = path.join(tmpRoot, "r400");
|
|
2424
|
+
await collectGroups({
|
|
2425
|
+
repoDir: origin,
|
|
2426
|
+
selected: [sA],
|
|
2427
|
+
requested: [sA.gate],
|
|
2428
|
+
collectDir: collDir,
|
|
2429
|
+
log: quiet,
|
|
2430
|
+
logError: quiet,
|
|
2431
|
+
tmpRoot,
|
|
2432
|
+
coreGroupGate: sA.gate,
|
|
2433
|
+
env: { GITHUB_RUN_ID: "400", GITHUB_RUN_ATTEMPT: "1" },
|
|
2434
|
+
});
|
|
2435
|
+
const sha = spawnSync("git", ["-C", origin, "rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
|
2436
|
+
const cur = currentCandidate(origin, [sA], sA.gate);
|
|
2437
|
+
const zr = spawnSync(
|
|
2438
|
+
"python3",
|
|
2439
|
+
[
|
|
2440
|
+
"-c",
|
|
2441
|
+
"import sys, os, io, zipfile, base64\nd = sys.argv[1]; out = io.BytesIO()\nwith zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:\n for n in sorted(os.listdir(d)): z.write(os.path.join(d, n), n)\nsys.stdout.write(base64.b64encode(out.getvalue()).decode())",
|
|
2442
|
+
collDir,
|
|
2443
|
+
],
|
|
2444
|
+
{ encoding: "utf8" },
|
|
2445
|
+
);
|
|
2446
|
+
assert.equal(zr.status, 0, `fixture zip failed: ${zr.stderr}`);
|
|
2447
|
+
const zip = Buffer.from(zr.stdout, "base64");
|
|
2448
|
+
const digest = `sha256:${createHash("sha256").update(zip).digest("hex")}`;
|
|
2449
|
+
type Run = Record<string, unknown>;
|
|
2450
|
+
const baseRun = (over: Run = {}): Run => ({
|
|
2451
|
+
id: 400,
|
|
2452
|
+
run_attempt: 1,
|
|
2453
|
+
head_sha: sha,
|
|
2454
|
+
event: "workflow_dispatch",
|
|
2455
|
+
status: "completed",
|
|
2456
|
+
conclusion: "success",
|
|
2457
|
+
path: ".github/workflows/ci.yml",
|
|
2458
|
+
head_repository: { full_name: repo },
|
|
2459
|
+
...over,
|
|
2460
|
+
});
|
|
2461
|
+
const goodJobs = (
|
|
2462
|
+
patch: (
|
|
2463
|
+
jobs: { name: string; conclusion: string; steps: { name: string; conclusion: string }[] }[],
|
|
2464
|
+
) => void = () => {},
|
|
2465
|
+
) => {
|
|
2466
|
+
const jobs = ["check", "install-surface", "artifact-consumer", "macos-install-surface"].map((name) => ({
|
|
2467
|
+
name,
|
|
2468
|
+
conclusion: "success",
|
|
2469
|
+
steps:
|
|
2470
|
+
name === "check"
|
|
2471
|
+
? [
|
|
2472
|
+
{ name: "collect qualification receipts (not a verdict)", conclusion: "success" },
|
|
2473
|
+
{ name: "upload qualification collection", conclusion: "success" },
|
|
2474
|
+
]
|
|
2475
|
+
: [],
|
|
2476
|
+
}));
|
|
2477
|
+
patch(jobs);
|
|
2478
|
+
return { jobs };
|
|
2479
|
+
};
|
|
2480
|
+
const mockApi = (opts: {
|
|
2481
|
+
runs?: Run[];
|
|
2482
|
+
jobs?: Record<string, unknown>;
|
|
2483
|
+
artifacts?: Record<string, unknown>;
|
|
2484
|
+
}): GhApi => {
|
|
2485
|
+
const runs = opts.runs ?? [baseRun()];
|
|
2486
|
+
const table: Record<string, unknown> = {
|
|
2487
|
+
[`repos/${repo}/actions/runs?head_sha=${sha}&per_page=100`]: { workflow_runs: runs },
|
|
2488
|
+
};
|
|
2489
|
+
for (const r of runs) {
|
|
2490
|
+
table[`repos/${repo}/actions/runs/${r.id}/attempts/${r.run_attempt}/jobs?per_page=100`] =
|
|
2491
|
+
opts.jobs?.[String(r.id)] ?? goodJobs();
|
|
2492
|
+
table[`repos/${repo}/actions/runs/${r.id}/artifacts?per_page=100`] = opts.artifacts?.[String(r.id)] ?? {
|
|
2493
|
+
artifacts: [
|
|
2494
|
+
{
|
|
2495
|
+
id: 9,
|
|
2496
|
+
name: artifactName(String(r.id), String(r.run_attempt)),
|
|
2497
|
+
digest,
|
|
2498
|
+
expired: false,
|
|
2499
|
+
workflow_run: { id: r.id, head_sha: sha },
|
|
2500
|
+
},
|
|
2501
|
+
],
|
|
2502
|
+
};
|
|
2503
|
+
}
|
|
2504
|
+
return {
|
|
2505
|
+
json: (p: string) => {
|
|
2506
|
+
if (!(p in table)) throw new Error(`mock: no fixture for ${p}`);
|
|
2507
|
+
return table[p];
|
|
2508
|
+
},
|
|
2509
|
+
bytes: (p: string) => {
|
|
2510
|
+
if (p !== `repos/${repo}/actions/artifacts/9/zip`) throw new Error(`mock: no bytes for ${p}`);
|
|
2511
|
+
return zip;
|
|
2512
|
+
},
|
|
2513
|
+
};
|
|
2514
|
+
};
|
|
2515
|
+
const final = (api: GhApi) => finalRunEvidence(api, repo, sha, cur.coreFiles ?? {});
|
|
2516
|
+
const has = (r: { errors: string[] }, prefix: string) => r.errors.some((e) => e.startsWith(prefix));
|
|
2517
|
+
|
|
2518
|
+
const good = final(mockApi({}));
|
|
2519
|
+
ok(
|
|
2520
|
+
"gh: the newest ci.yml dispatch run, four required jobs, the collect + upload steps and a digest-true zip → admitted, run 400#1",
|
|
2521
|
+
good.errors.length === 0 && good.runId === "400" && good.runAttempt === "1" && good.collection?.runId === "400",
|
|
2522
|
+
);
|
|
2523
|
+
const fork = final(mockApi({ runs: [baseRun({ head_repository: { full_name: "someone/fork" } })] }));
|
|
2524
|
+
const otherWf = final(mockApi({ runs: [baseRun({ path: ".github/workflows/other.yml" })] }));
|
|
2525
|
+
const pr = final(mockApi({ runs: [baseRun({ event: "pull_request" })] }));
|
|
2526
|
+
const failed = final(mockApi({ runs: [baseRun({ conclusion: "failure" })] }));
|
|
2527
|
+
ok(
|
|
2528
|
+
"[QK:GH-PROVENANCE-BOUNDARY] gh: a fork's run, another workflow's run, a pull_request run, a failed run → each refused by name",
|
|
2529
|
+
has(fork, "run-repository:") &&
|
|
2530
|
+
has(otherWf, "run-missing:") &&
|
|
2531
|
+
has(pr, "run-event: pull_request") &&
|
|
2532
|
+
has(failed, "run-conclusion: failure"),
|
|
2533
|
+
);
|
|
2534
|
+
// The newer run 401 has every job and step green but no artifact: it is still the one
|
|
2535
|
+
// judged (refused for its own missing artifact), independent of the step checks.
|
|
2536
|
+
const newerBare = final(
|
|
2537
|
+
mockApi({
|
|
2538
|
+
runs: [baseRun(), baseRun({ id: 401, event: "push" })],
|
|
2539
|
+
artifacts: { 401: { artifacts: [] } },
|
|
2540
|
+
}),
|
|
2541
|
+
);
|
|
2542
|
+
ok(
|
|
2543
|
+
"[QK:GH-NEWEST-RUN-ONLY] gh: a newer run at the SHA is the one judged — refused for its own missing artifact, never a fallback to the older collecting run",
|
|
2544
|
+
has(newerBare, "artifact-select: 0 artifacts named qualification-collection-401-1") && newerBare.runId === "401",
|
|
2545
|
+
);
|
|
2546
|
+
const skipped = final(mockApi({ jobs: { 400: goodJobs((j) => (j[1].conclusion = "skipped")) } }));
|
|
2547
|
+
const collectFailed = final(mockApi({ jobs: { 400: goodJobs((j) => (j[0].steps[0].conclusion = "failure")) } }));
|
|
2548
|
+
const noUpload = final(mockApi({ jobs: { 400: goodJobs((j) => j[0].steps.pop()) } }));
|
|
2549
|
+
ok(
|
|
2550
|
+
"[QK:GH-COLLECT-STEPS-REQUIRED] gh: a skipped required job, a failed collect step, a missing upload step → each named; step success is a precondition, never a verdict",
|
|
2551
|
+
has(skipped, "required-job: install-surface concluded skipped") &&
|
|
2552
|
+
has(collectFailed, 'composite-step: "collect qualification receipts (not a verdict)" concluded failure') &&
|
|
2553
|
+
has(noUpload, 'composite-step: no step named "upload qualification collection"'),
|
|
2554
|
+
);
|
|
2555
|
+
const attempt2 = final(
|
|
2556
|
+
mockApi({
|
|
2557
|
+
runs: [baseRun({ run_attempt: 2 })],
|
|
2558
|
+
artifacts: {
|
|
2559
|
+
400: {
|
|
2560
|
+
artifacts: [
|
|
2561
|
+
{
|
|
2562
|
+
id: 9,
|
|
2563
|
+
name: artifactName("400", "1"),
|
|
2564
|
+
digest,
|
|
2565
|
+
expired: false,
|
|
2566
|
+
workflow_run: { id: 400, head_sha: sha },
|
|
2567
|
+
},
|
|
2568
|
+
],
|
|
2569
|
+
},
|
|
2570
|
+
},
|
|
2571
|
+
}),
|
|
2572
|
+
);
|
|
2573
|
+
ok(
|
|
2574
|
+
"[QK:GH-ATTEMPT-NO-FALLBACK] gh: attempt 2 has no artifact while attempt 1 does → refused by name, never read",
|
|
2575
|
+
has(attempt2, "artifact-attempt: attempt 2 has no artifact") && attempt2.files === null,
|
|
2576
|
+
);
|
|
2577
|
+
const badDigest = final(
|
|
2578
|
+
mockApi({
|
|
2579
|
+
artifacts: {
|
|
2580
|
+
400: {
|
|
2581
|
+
artifacts: [
|
|
2582
|
+
{
|
|
2583
|
+
id: 9,
|
|
2584
|
+
name: artifactName("400", "1"),
|
|
2585
|
+
digest: `sha256:${"0".repeat(64)}`,
|
|
2586
|
+
expired: false,
|
|
2587
|
+
workflow_run: { id: 400, head_sha: sha },
|
|
2588
|
+
},
|
|
2589
|
+
],
|
|
2590
|
+
},
|
|
2591
|
+
},
|
|
2592
|
+
}),
|
|
2593
|
+
);
|
|
2594
|
+
ok(
|
|
2595
|
+
"gh: the artifact's recorded digest is checked against the downloaded zip bytes",
|
|
2596
|
+
has(badDigest, "artifact-digest:"),
|
|
2597
|
+
);
|
|
2598
|
+
// A NEWER run at the ancestor SHA that ATTEMPTED a collection and failed must not be
|
|
2599
|
+
// papered over by the older green one; a newer run that never attempted (a push or
|
|
2600
|
+
// schedule run: collect step absent or skipped, no artifact) is not a collection and
|
|
2601
|
+
// does not displace it.
|
|
2602
|
+
const ancNewerFailed = ancestorRunEvidence(
|
|
2603
|
+
mockApi({
|
|
2604
|
+
runs: [baseRun(), baseRun({ id: 401, conclusion: "failure" })],
|
|
2605
|
+
jobs: { 401: goodJobs((j) => (j[0].steps[0].conclusion = "failure")) },
|
|
2606
|
+
}),
|
|
2607
|
+
repo,
|
|
2608
|
+
sha,
|
|
2609
|
+
"400",
|
|
2610
|
+
);
|
|
2611
|
+
const ancNewerNoAttempt = ancestorRunEvidence(
|
|
2612
|
+
mockApi({
|
|
2613
|
+
runs: [baseRun(), baseRun({ id: 402, event: "push" })],
|
|
2614
|
+
jobs: {
|
|
2615
|
+
402: goodJobs((j) => {
|
|
2616
|
+
j[0].steps = [
|
|
2617
|
+
{ name: "collect qualification receipts (not a verdict)", conclusion: "skipped" },
|
|
2618
|
+
{ name: "upload qualification collection", conclusion: "skipped" },
|
|
2619
|
+
];
|
|
2620
|
+
}),
|
|
2621
|
+
},
|
|
2622
|
+
artifacts: { 402: { artifacts: [] } },
|
|
2623
|
+
}),
|
|
2624
|
+
repo,
|
|
2625
|
+
sha,
|
|
2626
|
+
"400",
|
|
2627
|
+
);
|
|
2628
|
+
ok(
|
|
2629
|
+
"[QK:GH-NEWER-FAILED-COLLECTION-NOT-PAPERED] gh: a newer run that attempted a collection and failed rejects the older green ancestor; a newer run that never attempted one does not",
|
|
2630
|
+
has(ancNewerFailed, "run-newer-collection-failed: run 401") && ancNewerNoAttempt.errors.length === 0,
|
|
2631
|
+
);
|
|
2632
|
+
const ancOk = ancestorRunEvidence(mockApi({}), repo, sha, "400");
|
|
2633
|
+
const ancOld = ancestorRunEvidence(mockApi({ runs: [baseRun(), baseRun({ id: 401 })] }), repo, sha, "400");
|
|
2634
|
+
ok(
|
|
2635
|
+
"gh: an ancestor run is admitted only while no newer run at that SHA also collected",
|
|
2636
|
+
ancOk.errors.length === 0 && has(ancOld, "run-not-newest: run 401"),
|
|
2637
|
+
);
|
|
2638
|
+
ok(
|
|
2639
|
+
"gh: --groups all parses to the literal, and resolves to every exact argv once in manifest order",
|
|
2640
|
+
(() => {
|
|
2641
|
+
const a = parseQualificationArgs(["--groups", "all", "--collect", "/x/c"]);
|
|
2642
|
+
const specs = [
|
|
2643
|
+
sA,
|
|
2644
|
+
fixtureSpec({ claim: "X2" }),
|
|
2645
|
+
fixtureSpec({ claim: "X3", gate: ["bash", "gates/survive.sh"] }),
|
|
2646
|
+
];
|
|
2647
|
+
return (
|
|
2648
|
+
a.mode === "groups" &&
|
|
2649
|
+
a.groups === "all" &&
|
|
2650
|
+
JSON.stringify(allGroupArgv(specs)) === JSON.stringify([sA.gate, ["bash", "gates/survive.sh"]])
|
|
2651
|
+
);
|
|
2652
|
+
})(),
|
|
2653
|
+
);
|
|
2654
|
+
} finally {
|
|
2655
|
+
fs.rmSync(origin, { recursive: true, force: true });
|
|
2656
|
+
fs.rmSync(path.dirname(externalTarget), { recursive: true, force: true });
|
|
2657
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
|
|
2661
|
+
// ═══ Phase 1c-ci — the CI surface of the composite collection, pinned statically (#124) ═
|
|
2662
|
+
//
|
|
2663
|
+
// ci.yml is read as text. The collection must live INSIDE the check job, after the body,
|
|
2664
|
+
// dispatch-only, with its input carried by env; the required jobs, the unnamed body step
|
|
2665
|
+
// and the single herdr install must stay exactly as the release oracle and the herdr
|
|
2666
|
+
// supply gate expect.
|
|
2667
|
+
|
|
2668
|
+
{
|
|
2669
|
+
// GitHub expression text, assembled so no JS string literal carries `${{`.
|
|
2670
|
+
const gh = (expr: string): string => `$${"{{"} ${expr} }}`;
|
|
2671
|
+
const ci = fs.readFileSync(path.join(REPO_DIR, ".github/workflows/ci.yml"), "utf8");
|
|
2672
|
+
const count = (needle: string) => ci.split(needle).length - 1;
|
|
2673
|
+
const jobsAt = ci.indexOf("\njobs:\n");
|
|
2674
|
+
const jobNames = [...ci.slice(jobsAt).matchAll(/^ {2}([a-z][a-z0-9-]*):\s*$/gm)].map((m) => m[1]);
|
|
2675
|
+
const checkStart = ci.indexOf("\n check:\n");
|
|
2676
|
+
const checkEnd = ci.indexOf("\n install-surface:\n");
|
|
2677
|
+
const body = ci.indexOf(" - run: ./run.sh check-gate-qualification\n");
|
|
2678
|
+
const collectAt = ci.indexOf(" - name: collect qualification receipts (not a verdict)\n");
|
|
2679
|
+
const uploadAt = ci.indexOf(" - name: upload qualification collection\n");
|
|
2680
|
+
const stepText = (at: number) =>
|
|
2681
|
+
ci.slice(
|
|
2682
|
+
at,
|
|
2683
|
+
ci.indexOf("\n - ", at + 1) === -1 ? checkEnd : Math.min(ci.indexOf("\n - ", at + 1), checkEnd),
|
|
2684
|
+
);
|
|
2685
|
+
const collect = stepText(collectAt);
|
|
2686
|
+
const upload = stepText(uploadAt);
|
|
2687
|
+
const dispatchOnly = (t: string) =>
|
|
2688
|
+
/\n {8}if: github\.event_name == 'workflow_dispatch' && inputs\.composite_groups != ''/.test(t);
|
|
2689
|
+
const runLine = /\n {8}run: (.*)/.exec(collect)?.[1] ?? "";
|
|
2690
|
+
const nameLine = /\n {10}name: (.*)/.exec(upload)?.[1] ?? "";
|
|
2691
|
+
ok(
|
|
2692
|
+
"[QK:CI-COMPOSITE-STEPS-PINNED] ci.yml: required jobs unchanged, body exactly once, herdr installed once, and the collect + upload steps sit inside check after the body, dispatch-only, input via env, artifact named per run attempt",
|
|
2693
|
+
JSON.stringify(jobNames) === JSON.stringify(REQUIRED_JOBS) &&
|
|
2694
|
+
count("- run: ./run.sh check-gate-qualification") === 1 &&
|
|
2695
|
+
count("./scripts/install-herdr-ci.sh") === 1 &&
|
|
2696
|
+
count("- name: collect qualification receipts (not a verdict)") === 1 &&
|
|
2697
|
+
count("- name: upload qualification collection") === 1 &&
|
|
2698
|
+
checkStart !== -1 &&
|
|
2699
|
+
body > checkStart &&
|
|
2700
|
+
collectAt > body &&
|
|
2701
|
+
uploadAt > collectAt &&
|
|
2702
|
+
uploadAt < checkEnd &&
|
|
2703
|
+
dispatchOnly(collect) &&
|
|
2704
|
+
dispatchOnly(upload) &&
|
|
2705
|
+
collect.includes(`\n env:\n COMPOSITE_GROUPS: ${gh("inputs.composite_groups")}`) &&
|
|
2706
|
+
runLine ===
|
|
2707
|
+
'node --experimental-strip-types scripts/check-gate-qualification.ts --groups "$COMPOSITE_GROUPS" --collect "$RUNNER_TEMP/qualification-collection"' &&
|
|
2708
|
+
!runLine.includes(gh("").slice(0, 3)) &&
|
|
2709
|
+
upload.includes("uses: actions/upload-artifact@v4") &&
|
|
2710
|
+
nameLine === `qualification-collection-${gh("github.run_id")}-${gh("github.run_attempt")}` &&
|
|
2711
|
+
artifactName(gh("github.run_id"), gh("github.run_attempt")) === nameLine &&
|
|
2712
|
+
COMPOSITE_JOB === "check" &&
|
|
2713
|
+
/\n {6}composite_groups:\n(?: {8}.*\n)*? {8}type: string\n {8}default: ''\n/.test(ci),
|
|
2714
|
+
);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
// ═══ Phase 1c-o — the exact-SHA oracle: core over mocks, launcher over synthetic repos (#124 P1) ═
|
|
2718
|
+
//
|
|
2719
|
+
// The core's decision path runs in-process against an injected GhApi (no network, no
|
|
2720
|
+
// ghCli). The launcher's clone boundary runs against TEMPORARY synthetic git repositories
|
|
2721
|
+
// whose commits are test data only; their "oracle core" is a stub that prints a marker and
|
|
2722
|
+
// emits NO qualification class, so this proves which bytes ran, never a verdict.
|
|
2723
|
+
|
|
2724
|
+
{
|
|
2725
|
+
const tmpRoot = reclaimOnExit(fs.mkdtempSync(path.join(os.tmpdir(), "entwurf-oracle-selftest-")));
|
|
2726
|
+
const { dir: origin, externalTarget } = buildFixtureOrigin();
|
|
2727
|
+
const fxEnv = {
|
|
2728
|
+
...process.env,
|
|
2729
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
2730
|
+
GIT_CONFIG_SYSTEM: "/dev/null",
|
|
2731
|
+
GIT_AUTHOR_NAME: "fx",
|
|
2732
|
+
GIT_AUTHOR_EMAIL: "fx@localhost",
|
|
2733
|
+
GIT_COMMITTER_NAME: "fx",
|
|
2734
|
+
GIT_COMMITTER_EMAIL: "fx@localhost",
|
|
2735
|
+
};
|
|
2736
|
+
const gitAt = (dir: string, ...args: string[]): string => {
|
|
2737
|
+
const r = spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", env: fxEnv });
|
|
2738
|
+
assert.equal(r.status, 0, `fixture git ${args.join(" ")} failed: ${r.stderr}`);
|
|
2739
|
+
return r.stdout.trim();
|
|
2740
|
+
};
|
|
2741
|
+
const commitAll = (dir: string, msg: string): string => {
|
|
2742
|
+
gitAt(dir, "add", "-A");
|
|
2743
|
+
gitAt(dir, "-c", "commit.gpgsign=false", "commit", "-q", "-m", msg);
|
|
2744
|
+
return gitAt(dir, "rev-parse", "HEAD");
|
|
2745
|
+
};
|
|
2746
|
+
try {
|
|
2747
|
+
// ── parser, self-check, content projection ──
|
|
2748
|
+
const S = "a".repeat(40);
|
|
2749
|
+
const T = "b".repeat(40);
|
|
2750
|
+
const refuseO = (argv: string[], naming: string) =>
|
|
2751
|
+
throws(`oracle args: ${JSON.stringify(argv)} is refused`, () => parseOracleArgs(argv), naming);
|
|
2752
|
+
const base = ["--sha", S, "--repo", "o/r", "--final-run", "10"];
|
|
2753
|
+
refuseO([...base, "--ancestor-run", `${T}:5`, "--ancestor-run", `${T}:6`], "repeats SHA");
|
|
2754
|
+
refuseO([...base, "--ancestor-run", `${T}:5`, "--ancestor-run", `${"c".repeat(40)}:5`], "repeats run");
|
|
2755
|
+
refuseO([...base, "--ancestor-run", `${S}:5`], "final SHA cannot also be an ancestor");
|
|
2756
|
+
refuseO([...base, "--ancestor-run", `${T}:10`], "final run cannot also be an ancestor run");
|
|
2757
|
+
refuseO(["--sha", "abc", "--repo", "o/r", "--final-run", "1"], "full 40-hex SHA");
|
|
2758
|
+
refuseO([...base, "--origin", "/x"], "unknown argument --origin");
|
|
2759
|
+
refuseO([...base, "--self-check-only"], "unknown argument --self-check-only");
|
|
2760
|
+
refuseO(["--sha", S], "are required");
|
|
2761
|
+
refuseO([...base, "--require-group", "bash run.sh x"], "JSON argv array");
|
|
2762
|
+
refuseO([...base, "--require-group", "[]"], "non-empty JSON array");
|
|
2763
|
+
refuseO([...base, "--require-group", '["bash", 1]'], "non-empty JSON array");
|
|
2764
|
+
refuseO([...base, "--require-group", '["a"]', "--require-group", '["a"]'], "repeats");
|
|
2765
|
+
ok(
|
|
2766
|
+
"oracle args: --require-group is repeatable, parsed as exact argv, order kept",
|
|
2767
|
+
JSON.stringify(
|
|
2768
|
+
parseOracleArgs([...base, "--require-group", '["b","x"]', "--require-group", '["a"]']).declared,
|
|
2769
|
+
) === JSON.stringify([["b", "x"], ["a"]]) && parseOracleArgs(base).declared.length === 0,
|
|
2770
|
+
);
|
|
2771
|
+
const gatesKnown = [{ gate: ["bash", "gates/a.sh"] }, { gate: ["bash", "gates/b.sh"] }];
|
|
2772
|
+
ok(
|
|
2773
|
+
"oracle core: a declared group must be EXACTLY a group gate of the tree — prefix, trailing space, unknown → each refused",
|
|
2774
|
+
checkDeclared([["bash", "gates/a.sh"]], gatesKnown).length === 0 &&
|
|
2775
|
+
checkDeclared([["bash"]], gatesKnown).length === 1 &&
|
|
2776
|
+
checkDeclared([["bash", "gates/a.sh "]], gatesKnown).length === 1 &&
|
|
2777
|
+
checkDeclared([["bash", "gates/c.sh"]], gatesKnown)[0].startsWith("require-group-unknown"),
|
|
2778
|
+
);
|
|
2779
|
+
const fakeGit =
|
|
2780
|
+
(head: string, porcelain: string): GitRunner =>
|
|
2781
|
+
(args) =>
|
|
2782
|
+
args[0] === "rev-parse" ? { status: 0, stdout: `${head}\n` } : { status: 0, stdout: porcelain };
|
|
2783
|
+
ok(
|
|
2784
|
+
"[QK:ORACLE-SELF-CHECK] oracle core: a checkout at another SHA, or a dirty one, refuses itself; the exact clean SHA passes",
|
|
2785
|
+
selfCheck(fakeGit(T, ""), S).some((e) => e.includes(`not ${S}`)) &&
|
|
2786
|
+
selfCheck(fakeGit(S, " M x\n"), S).includes("self-check: this checkout is not clean") &&
|
|
2787
|
+
selfCheck(fakeGit(S, ""), S).length === 0,
|
|
2788
|
+
);
|
|
2789
|
+
|
|
2790
|
+
// ── runOracle over an injected GhApi ──
|
|
2791
|
+
const sA = fixtureSpec({});
|
|
2792
|
+
const sB = fixtureSpec({
|
|
2793
|
+
claim: "SELFTEST-SURVIVE",
|
|
2794
|
+
gate: ["bash", "gates/survive.sh"],
|
|
2795
|
+
signature: "[QK:SELFTEST-SURVIVE]",
|
|
2796
|
+
});
|
|
2797
|
+
const sha0 = commitAll(origin, "oracle fixture base");
|
|
2798
|
+
const collectAt = async (name: string, runId: string, set: MutantSpec[]) => {
|
|
2799
|
+
const dir = path.join(tmpRoot, name);
|
|
2800
|
+
await collectGroups({
|
|
2801
|
+
repoDir: origin,
|
|
2802
|
+
selected: set,
|
|
2803
|
+
requested: set.map((m) => m.gate),
|
|
2804
|
+
collectDir: dir,
|
|
2805
|
+
log: quiet,
|
|
2806
|
+
logError: quiet,
|
|
2807
|
+
tmpRoot,
|
|
2808
|
+
coreGroupGate: sA.gate,
|
|
2809
|
+
env: { GITHUB_RUN_ID: runId, GITHUB_RUN_ATTEMPT: "1" },
|
|
2810
|
+
});
|
|
2811
|
+
const z = spawnSync(
|
|
2812
|
+
"python3",
|
|
2813
|
+
[
|
|
2814
|
+
"-c",
|
|
2815
|
+
"import sys, os, io, zipfile, base64\nd = sys.argv[1]; out = io.BytesIO()\nwith zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:\n for n in sorted(os.listdir(d)): z.write(os.path.join(d, n), n)\nsys.stdout.write(base64.b64encode(out.getvalue()).decode())",
|
|
2816
|
+
dir,
|
|
2817
|
+
],
|
|
2818
|
+
{ encoding: "utf8" },
|
|
2819
|
+
);
|
|
2820
|
+
assert.equal(z.status, 0, `fixture zip failed: ${z.stderr}`);
|
|
2821
|
+
return Buffer.from(z.stdout, "base64");
|
|
2822
|
+
};
|
|
2823
|
+
const zipA = await collectAt("r500", "500", [sA]);
|
|
2824
|
+
const zipAB = await collectAt("r501", "501", [sA, sB]);
|
|
2825
|
+
const repo = "owner/entwurf";
|
|
2826
|
+
const apiFor = (runId: number, zip: Buffer): GhApi => {
|
|
2827
|
+
const run = {
|
|
2828
|
+
id: runId,
|
|
2829
|
+
run_attempt: 1,
|
|
2830
|
+
head_sha: sha0,
|
|
2831
|
+
event: "workflow_dispatch",
|
|
2832
|
+
status: "completed",
|
|
2833
|
+
conclusion: "success",
|
|
2834
|
+
path: ".github/workflows/ci.yml",
|
|
2835
|
+
head_repository: { full_name: repo },
|
|
2836
|
+
};
|
|
2837
|
+
const jobs = ["check", "install-surface", "artifact-consumer", "macos-install-surface"].map((name) => ({
|
|
2838
|
+
name,
|
|
2839
|
+
conclusion: "success",
|
|
2840
|
+
steps:
|
|
2841
|
+
name === "check"
|
|
2842
|
+
? [
|
|
2843
|
+
{ name: "collect qualification receipts (not a verdict)", conclusion: "success" },
|
|
2844
|
+
{ name: "upload qualification collection", conclusion: "success" },
|
|
2845
|
+
]
|
|
2846
|
+
: [],
|
|
2847
|
+
}));
|
|
2848
|
+
const digest = `sha256:${createHash("sha256").update(zip).digest("hex")}`;
|
|
2849
|
+
return {
|
|
2850
|
+
json: (p: string) => {
|
|
2851
|
+
if (p.startsWith(`repos/${repo}/actions/runs?head_sha=`)) {
|
|
2852
|
+
return { workflow_runs: p.includes(sha0) ? [run] : [] };
|
|
2853
|
+
}
|
|
2854
|
+
if (p === `repos/${repo}/actions/runs/${runId}/attempts/1/jobs?per_page=100`) return { jobs };
|
|
2855
|
+
if (p === `repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`) {
|
|
2856
|
+
return {
|
|
2857
|
+
artifacts: [
|
|
2858
|
+
{
|
|
2859
|
+
id: 9,
|
|
2860
|
+
name: artifactName(String(runId), "1"),
|
|
2861
|
+
digest,
|
|
2862
|
+
expired: false,
|
|
2863
|
+
workflow_run: { id: runId, head_sha: sha0 },
|
|
2864
|
+
},
|
|
2865
|
+
],
|
|
2866
|
+
};
|
|
2867
|
+
}
|
|
2868
|
+
throw new Error(`mock: no fixture for ${p}`);
|
|
2869
|
+
},
|
|
2870
|
+
bytes: (p: string) => {
|
|
2871
|
+
if (p !== `repos/${repo}/actions/artifacts/9/zip`) throw new Error(`mock: no bytes for ${p}`);
|
|
2872
|
+
return zip;
|
|
2873
|
+
},
|
|
2874
|
+
};
|
|
2875
|
+
};
|
|
2876
|
+
const firstParent = gitAt(origin, "rev-list", "--first-parent", sha0).split("\n");
|
|
2877
|
+
const oracleAt = (
|
|
2878
|
+
dir: string,
|
|
2879
|
+
set: MutantSpec[],
|
|
2880
|
+
api: GhApi,
|
|
2881
|
+
over: Partial<Parameters<typeof runOracle>[0]> = {},
|
|
2882
|
+
) =>
|
|
2883
|
+
runOracle({
|
|
2884
|
+
api,
|
|
2885
|
+
repo,
|
|
2886
|
+
sha: sha0,
|
|
2887
|
+
finalRunId: String((api as unknown as { runId?: number }).runId ?? 500),
|
|
2888
|
+
ancestors: [],
|
|
2889
|
+
independent: contentCandidate(dir, set, sA.gate),
|
|
2890
|
+
firstParent,
|
|
2891
|
+
operatorDeclared: [],
|
|
2892
|
+
...over,
|
|
2893
|
+
});
|
|
2894
|
+
const content = contentCandidate(origin, [sA], sA.gate);
|
|
2895
|
+
ok(
|
|
2896
|
+
"oracle core: the content projection carries no C0 environment part (no coreSha256, no node/node_modules)",
|
|
2897
|
+
!("coreSha256" in content) && JSON.stringify(content).indexOf("nodeModules") === -1 && content.porcelainClean,
|
|
2898
|
+
);
|
|
2899
|
+
const green1 = oracleAt(origin, [sA], apiFor(500, zipA));
|
|
2900
|
+
const green2 = oracleAt(origin, [sA], apiFor(500, zipA));
|
|
2901
|
+
// Two clones in two different places, same CI inputs → the same decision digest.
|
|
2902
|
+
const cloneA = path.join(tmpRoot, "clone-a");
|
|
2903
|
+
const cloneB = path.join(tmpRoot, "elsewhere", "clone-b");
|
|
2904
|
+
fs.mkdirSync(path.dirname(cloneB), { recursive: true });
|
|
2905
|
+
for (const c of [cloneA, cloneB]) {
|
|
2906
|
+
const r = spawnSync("git", ["clone", "-q", "--no-hardlinks", origin, c], { env: fxEnv });
|
|
2907
|
+
assert.equal(r.status, 0, "fixture clone failed");
|
|
2908
|
+
}
|
|
2909
|
+
const atA = oracleAt(cloneA, [sA], apiFor(500, zipA));
|
|
2910
|
+
const atB = oracleAt(cloneB, [sA], apiFor(500, zipA));
|
|
2911
|
+
ok(
|
|
2912
|
+
"[QK:ORACLE-DIGEST-MACHINE-INDEPENDENT] oracle core: COMPOSITE-GREEN, and the ledger digest is identical across repeats and across two clone locations",
|
|
2913
|
+
green1.ledger.verdict === "COMPOSITE-GREEN" &&
|
|
2914
|
+
green1.ledgerSha256 === green2.ledgerSha256 &&
|
|
2915
|
+
atA.ledgerSha256 === green1.ledgerSha256 &&
|
|
2916
|
+
atB.ledgerSha256 === green1.ledgerSha256 &&
|
|
2917
|
+
/^[0-9a-f]{64}$/.test(green1.ledgerSha256),
|
|
2918
|
+
);
|
|
2919
|
+
ok(
|
|
2920
|
+
"oracle core: the digest the oracle prints is the one an acceptance record is checked against (sha256 of the canonical ledger)",
|
|
2921
|
+
decisionDigest(green1.ledger) === sha256(canonicalJson(green1.ledger)) &&
|
|
2922
|
+
green1.ledgerSha256 === sha256(canonicalJson(green1.ledger)),
|
|
2923
|
+
);
|
|
2924
|
+
const pinned = oracleAt(origin, [sA], apiFor(500, zipA), { finalRunId: "499" });
|
|
2925
|
+
ok(
|
|
2926
|
+
"[QK:ORACLE-FINAL-RUN-PINNED] oracle core: the newest run is not the pinned one → RED final-run-pinned-mismatch, nothing composed from it",
|
|
2927
|
+
pinned.ledger.verdict === "RED" && pinned.oracleErrors.some((e) => e.startsWith("final-run-pinned-mismatch")),
|
|
2928
|
+
);
|
|
2929
|
+
const unknownAncestor = oracleAt(origin, [sA], apiFor(500, zipA), {
|
|
2930
|
+
ancestors: [{ sha: "c".repeat(40), runId: "777" }],
|
|
2931
|
+
});
|
|
2932
|
+
const surfaceDrift = runOracle({
|
|
2933
|
+
api: apiFor(500, zipA),
|
|
2934
|
+
repo,
|
|
2935
|
+
sha: sha0,
|
|
2936
|
+
finalRunId: "500",
|
|
2937
|
+
ancestors: [],
|
|
2938
|
+
independent: { ...content, workSurfaceSha256: "0".repeat(64) },
|
|
2939
|
+
firstParent,
|
|
2940
|
+
operatorDeclared: [],
|
|
2941
|
+
});
|
|
2942
|
+
const redTail = runOracle({
|
|
2943
|
+
api: apiFor(501, zipAB),
|
|
2944
|
+
repo,
|
|
2945
|
+
sha: sha0,
|
|
2946
|
+
finalRunId: "501",
|
|
2947
|
+
ancestors: [],
|
|
2948
|
+
independent: contentCandidate(origin, [sA, sB], sA.gate),
|
|
2949
|
+
firstParent,
|
|
2950
|
+
operatorDeclared: [],
|
|
2951
|
+
});
|
|
2952
|
+
ok(
|
|
2953
|
+
"oracle core: an unknown ancestor run → RED; a CI-claimed surface the tree contradicts → RED; a COMPLETE final collection with a red group → RED",
|
|
2954
|
+
unknownAncestor.ledger.verdict === "RED" &&
|
|
2955
|
+
unknownAncestor.oracleErrors.some((e) => e.includes("run-missing: 777")) &&
|
|
2956
|
+
surfaceDrift.ledger.verdict === "RED" &&
|
|
2957
|
+
surfaceDrift.ledger.release.crossCheck.includes("workSurface") &&
|
|
2958
|
+
redTail.ledger.verdict === "RED" &&
|
|
2959
|
+
redTail.oracleErrors.length === 0,
|
|
2960
|
+
);
|
|
2961
|
+
|
|
2962
|
+
const declaredB = oracleAt(origin, [sA], apiFor(500, zipA), { operatorDeclared: [sB.gate] });
|
|
2963
|
+
ok(
|
|
2964
|
+
"[QK:ORACLE-DECLARED-REQUIRED] oracle core: a declared group reaches the release composer — in required, and NOT-QUALIFIED required-not-measured while the final run did not measure it",
|
|
2965
|
+
green1.ledger.release.required.length === 1 &&
|
|
2966
|
+
declaredB.ledger.verdict === "NOT-QUALIFIED" &&
|
|
2967
|
+
declaredB.ledger.release.required.some((g) => JSON.stringify(g) === JSON.stringify(sB.gate)) &&
|
|
2968
|
+
declaredB.ledger.blockers.some(
|
|
2969
|
+
(b) => b === `required-not-measured: ${sB.gate.join(" ")} must be exact-MEASURED on these bytes`,
|
|
2970
|
+
),
|
|
2971
|
+
);
|
|
2972
|
+
|
|
2973
|
+
// ── the launcher over synthetic repositories (test data) ──
|
|
2974
|
+
const synth = path.join(tmpRoot, "synth-origin");
|
|
2975
|
+
fs.mkdirSync(synth);
|
|
2976
|
+
gitAt(synth, "init", "-q");
|
|
2977
|
+
fs.writeFileSync(path.join(synth, "README"), "synthetic\n");
|
|
2978
|
+
const noCore = commitAll(synth, "no oracle core");
|
|
2979
|
+
const nonce = createHash("sha256").update(`${process.pid}-${Date.now()}`).digest("hex").slice(0, 12);
|
|
2980
|
+
fs.mkdirSync(path.join(synth, "scripts", "lib"), { recursive: true });
|
|
2981
|
+
fs.writeFileSync(
|
|
2982
|
+
path.join(synth, "scripts", "lib", "qualification-oracle-core.ts"),
|
|
2983
|
+
[
|
|
2984
|
+
'import { execFileSync } from "node:child_process";',
|
|
2985
|
+
'const head = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();',
|
|
2986
|
+
`console.log("STUB-MARKER-${nonce} cwd=" + process.cwd() + " head=" + head);`,
|
|
2987
|
+
"process.exit(7);",
|
|
2988
|
+
"",
|
|
2989
|
+
].join("\n"),
|
|
2990
|
+
);
|
|
2991
|
+
const withStub = commitAll(synth, "stub oracle core (test data, no qualification class)");
|
|
2992
|
+
fs.writeFileSync(
|
|
2993
|
+
path.join(synth, "scripts", "lib", "qualification-oracle-core.ts"),
|
|
2994
|
+
[
|
|
2995
|
+
'import { execFileSync } from "node:child_process";',
|
|
2996
|
+
'import { writeFileSync } from "node:fs";',
|
|
2997
|
+
'const origin = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();',
|
|
2998
|
+
'writeFileSync(origin + "/tampered.txt", "x");',
|
|
2999
|
+
"process.exit(0);",
|
|
3000
|
+
"",
|
|
3001
|
+
].join("\n"),
|
|
3002
|
+
);
|
|
3003
|
+
const tamper = commitAll(synth, "stub that writes the origin (test data)");
|
|
3004
|
+
fs.writeFileSync(path.join(synth, "dirty-untracked.txt"), "operator's uncommitted work\n");
|
|
3005
|
+
const cloneDirs = () => fs.readdirSync(os.tmpdir()).filter((f) => f.startsWith("entwurf-oracle-clone-")).length;
|
|
3006
|
+
const run = (sha: string, extra: string[] = []) => {
|
|
3007
|
+
const out: string[] = [];
|
|
3008
|
+
const err: string[] = [];
|
|
3009
|
+
const code = launch(
|
|
3010
|
+
["--sha", sha, "--repo", "o/r", "--final-run", "1", "--origin", synth, ...extra],
|
|
3011
|
+
(l) => out.push(l),
|
|
3012
|
+
(l) => err.push(l),
|
|
3013
|
+
);
|
|
3014
|
+
return { code, out: out.join("\n"), err: err.join("\n") };
|
|
3015
|
+
};
|
|
3016
|
+
const clonesBefore = cloneDirs();
|
|
3017
|
+
const missing = run(noCore);
|
|
3018
|
+
ok(
|
|
3019
|
+
"[QK:ORACLE-SOURCE-MISSING] launcher: a SHA that does not carry the oracle core → ABORT oracle-source-missing (the 8d508d3 shape), exit 2",
|
|
3020
|
+
missing.code === 2 && missing.err.includes("oracle-source-missing"),
|
|
3021
|
+
);
|
|
3022
|
+
const porcelainBefore = gitAt(synth, "status", "--porcelain");
|
|
3023
|
+
const stub = run(withStub);
|
|
3024
|
+
const marker = new RegExp(`STUB-MARKER-${nonce} cwd=(\\S+) head=([0-9a-f]{40})`).exec(stub.out);
|
|
3025
|
+
ok(
|
|
3026
|
+
"[QK:ORACLE-RUNS-TARGET-BYTES] launcher: the TARGET SHA's own core runs inside a clean clone (not the dirty origin), its exit code relayed; clone removed, origin untouched",
|
|
3027
|
+
stub.code === 7 &&
|
|
3028
|
+
marker !== null &&
|
|
3029
|
+
marker[2] === withStub &&
|
|
3030
|
+
path.resolve(marker[1]) !== path.resolve(synth) &&
|
|
3031
|
+
!fs.existsSync(marker[1]) &&
|
|
3032
|
+
gitAt(synth, "status", "--porcelain") === porcelainBefore &&
|
|
3033
|
+
cloneDirs() === clonesBefore,
|
|
3034
|
+
);
|
|
3035
|
+
const moved = run(tamper);
|
|
3036
|
+
ok(
|
|
3037
|
+
"[QK:ORACLE-ORIGIN-TRIPWIRE] launcher: a core that writes the origin while it runs → ABORT origin-moved, overriding its own exit 0",
|
|
3038
|
+
moved.code === 2 && moved.err.includes("origin-moved") && fs.existsSync(path.join(synth, "tampered.txt")),
|
|
3039
|
+
);
|
|
3040
|
+
fs.rmSync(path.join(synth, "tampered.txt"));
|
|
3041
|
+
const inside = run(withStub, ["--ledger-out", path.join(synth, "ledger.json")]);
|
|
3042
|
+
ok(
|
|
3043
|
+
"launcher: a --ledger-out inside the origin repo is refused before anything is cloned",
|
|
3044
|
+
inside.code === 2 && inside.err.includes("outside the repo") && !stub.out.includes("ledger"),
|
|
3045
|
+
);
|
|
3046
|
+
} finally {
|
|
3047
|
+
fs.rmSync(origin, { recursive: true, force: true });
|
|
3048
|
+
fs.rmSync(path.dirname(externalTarget), { recursive: true, force: true });
|
|
3049
|
+
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
3052
|
+
|
|
796
3053
|
console.log(`\n[${SURFACE}] self-test: ${passed} checks passed`);
|
|
797
3054
|
|
|
798
3055
|
// ═══ Phase 1d — the real manifests, VALIDATED but not yet run ═══════════════
|
|
@@ -868,6 +3125,7 @@ let manifestCount: number;
|
|
|
868
3125
|
"mux-parent-artifact": 3,
|
|
869
3126
|
"pack-install": 2,
|
|
870
3127
|
"pi-package-ownership": 8,
|
|
3128
|
+
"qualification-ledger": 42,
|
|
871
3129
|
"mux-resume-call": 12,
|
|
872
3130
|
"omp-birth": 13,
|
|
873
3131
|
"omp-fresh": 24,
|
|
@@ -952,12 +3210,94 @@ if (MANIFESTS_ONLY) {
|
|
|
952
3210
|
`[check-gate-manifests] ok — runner self-test green, ${selected.length} committed mutants across ` +
|
|
953
3211
|
`${manifestCount} lanes validated against the origin index, every one of their \`find\` strings still ` +
|
|
954
3212
|
"matching its subject exactly once, and the lane inventory matching its declared " +
|
|
955
|
-
"contract. ZERO mutants were executed and this repo was never snapshotted
|
|
956
|
-
"
|
|
3213
|
+
"contract. ZERO committed mutants were executed and this repo was never snapshotted (the " +
|
|
3214
|
+
"self-test ran only its synthetic fixture mutants, in tmp fixture repos): the body " +
|
|
3215
|
+
"(check-gate-qualification) owns the committed set, unchanged.",
|
|
957
3216
|
);
|
|
958
3217
|
process.exit(0);
|
|
959
3218
|
}
|
|
960
3219
|
|
|
3220
|
+
// ═══ Phase 2c — LOCAL composite ledger over explicit receipt files (#124) ═════
|
|
3221
|
+
//
|
|
3222
|
+
// Runs after the full head (self-test + committed manifests validated), then composes
|
|
3223
|
+
// the given receipts against THIS candidate. It executes no mutant and accepts no risk.
|
|
3224
|
+
// `--expect` is optional HERE; the offline composite oracle declares its inputs through each
|
|
3225
|
+
// collection's receipt digests under the artifact digest instead (qualification-ledger.ts).
|
|
3226
|
+
// Exit: 0 COMPOSITE-GREEN, 1 RED, 3 RISK-PENDING, 4 NOT-QUALIFIED. The ledger it can
|
|
3227
|
+
// write is a report; FULL-GREEN and RISK-ACCEPTED are not local verdicts.
|
|
3228
|
+
|
|
3229
|
+
if (ARGS.mode === "compose") {
|
|
3230
|
+
const expected = ARGS.expectPath ? parseExpectedList(fs.readFileSync(ARGS.expectPath, "utf8")) : undefined;
|
|
3231
|
+
const loaded = ARGS.receipts.map((f) => {
|
|
3232
|
+
try {
|
|
3233
|
+
return loadReceipt(f);
|
|
3234
|
+
} catch (err) {
|
|
3235
|
+
// An unreadable input is not skipped: it enters as an unparseable receipt (RED).
|
|
3236
|
+
return { source: f, text: `unreadable: ${err instanceof Error ? err.message : String(err)}` };
|
|
3237
|
+
}
|
|
3238
|
+
});
|
|
3239
|
+
const ledger = composeLedger(loaded, currentCandidate(REPO_DIR, selected), expected, {
|
|
3240
|
+
ledgerPath: ARGS.ledgerOut,
|
|
3241
|
+
});
|
|
3242
|
+
for (const line of ledger.summary) console.log(line);
|
|
3243
|
+
if (ARGS.ledgerOut) {
|
|
3244
|
+
writeReceiptExclusive(ARGS.ledgerOut, ledger);
|
|
3245
|
+
console.log(`[qualification-ledger] written: ${ARGS.ledgerOut}`);
|
|
3246
|
+
}
|
|
3247
|
+
const exitFor = { "COMPOSITE-GREEN": 0, RED: 1, "RISK-PENDING": 3, "NOT-QUALIFIED": 4 } as const;
|
|
3248
|
+
process.exit(exitFor[ledger.verdict]);
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
// ═══ Phase 2m — several exact-argv groups, one collection (#124 step 1) ═════
|
|
3252
|
+
//
|
|
3253
|
+
// Head once (above), then each requested group on its own fresh snapshot. Exit 0 means
|
|
3254
|
+
// only that the collection is COMPLETE — every requested group wrote a receipt, red ones
|
|
3255
|
+
// included. It is an inventory, never a verdict: compose it with --compose.
|
|
3256
|
+
|
|
3257
|
+
if (ARGS.mode === "groups") {
|
|
3258
|
+
const requested = ARGS.groups === "all" ? allGroupArgv(selected) : ARGS.groups;
|
|
3259
|
+
checkRequestedGroups(requested, selected);
|
|
3260
|
+
sweepStaleSnapshots(os.tmpdir());
|
|
3261
|
+
const c = await collectGroups({
|
|
3262
|
+
repoDir: REPO_DIR,
|
|
3263
|
+
selected,
|
|
3264
|
+
requested,
|
|
3265
|
+
collectDir: ARGS.collectDir,
|
|
3266
|
+
log: (line) => console.log(line),
|
|
3267
|
+
logError: (line) => console.error(line),
|
|
3268
|
+
});
|
|
3269
|
+
const problems = checkCollection(ARGS.collectDir);
|
|
3270
|
+
for (const p of problems) console.error(`[gate-qualification --groups] ${p}`);
|
|
3271
|
+
const red = c.attempted.filter((a) => a.detail === "group-RED").length;
|
|
3272
|
+
console.log(
|
|
3273
|
+
`[gate-qualification --groups] collection ${c.complete ? "COMPLETE" : "INCOMPLETE"} (not a verdict): ` +
|
|
3274
|
+
`${c.attempted.filter((a) => a.outcome === "receipt-written").length}/${c.requested.length} receipts written, ` +
|
|
3275
|
+
`${red} red; ${ARGS.collectDir}/collection.json`,
|
|
3276
|
+
);
|
|
3277
|
+
process.exit(c.complete && problems.length === 0 ? 0 : 1);
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
// ═══ Phase 2g — ONE exact-argv group, PARTIAL receipt (#124 P1) ════════════
|
|
3281
|
+
//
|
|
3282
|
+
// Same head, same snapshot, same qualifyMutants state machine — restricted to the
|
|
3283
|
+
// mutants whose gate argv equals `--group` exactly, in manifest order. A red is a
|
|
3284
|
+
// measurement too: the receipt is written first, then the exit is nonzero. An
|
|
3285
|
+
// exception inside the body writes nothing (it is not an observation). This never
|
|
3286
|
+
// prints `[gate-qualification] ok` and never claims the full set.
|
|
3287
|
+
|
|
3288
|
+
if (ARGS.mode === "group") {
|
|
3289
|
+
sweepStaleSnapshots(os.tmpdir());
|
|
3290
|
+
const { passed: groupPassed } = await runGroupReceipt({
|
|
3291
|
+
repoDir: REPO_DIR,
|
|
3292
|
+
selected,
|
|
3293
|
+
gate: ARGS.group,
|
|
3294
|
+
receiptPath: ARGS.receiptPath,
|
|
3295
|
+
log: (line) => console.log(line),
|
|
3296
|
+
logError: (line) => console.error(line),
|
|
3297
|
+
});
|
|
3298
|
+
process.exit(groupPassed ? 0 : 1);
|
|
3299
|
+
}
|
|
3300
|
+
|
|
961
3301
|
// ═══ Phase 2 — the real manifests against a snapshot of THIS repo ═══════════
|
|
962
3302
|
|
|
963
3303
|
{
|