@junghanacs/entwurf 0.36.1 → 0.37.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/AGENTS.md +2 -2
- package/CHANGELOG.md +301 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +5 -0
- package/VERIFY.md +26 -17
- package/docs/setup-clean-host.md +1 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +16 -1
- package/package.json +4 -3
- package/pi/pi-durable/bootstrap.mjs +113 -11
- package/pi/pi-durable/carrier/harness-setup.js +4 -1
- package/pi/pi-durable/carrier/prompt.js +31 -2
- package/pi/pi-durable/carrier/runtime.js +11 -2
- package/pi/pi-durable/carrier/sessions.js +75 -5
- package/pi/pi-durable/carrier/tui.js +23 -0
- package/pi/pi-durable/overlay/upstream-pin.json +10 -10
- package/pi-extensions/meta-bridge-pi-durable.ts +20 -0
- package/run.sh +44 -17
- package/scripts/check-gate-qualification.ts +3 -3
- package/scripts/check-release-gate-outcomes.ts +200 -387
- package/scripts/fixtures/herdr-supply.json +11 -10
- package/scripts/lib/codex-fresh-live-protocol.ts +6 -1
- package/scripts/lib/qualification-ledger.ts +2 -2
- package/scripts/mutants/herdr-supply.json +5 -5
- package/scripts/mutants/pi-durable.json +117 -45
- package/scripts/mutants/release-gate.json +18 -52
- package/scripts/smoke-mux-lifecycle-live.ts +1 -1
- package/scripts/ci-qualify-decide.sh +0 -256
- package/scripts/fixtures/qualify-replay.json +0 -149
|
@@ -26,8 +26,11 @@
|
|
|
26
26
|
// 7. every LIVE smoke is either wired into the aggregate or excluded for a
|
|
27
27
|
// reason the docs actually state — the protocol cannot vouch for a step the
|
|
28
28
|
// gate never lists;
|
|
29
|
-
// 8. the moved check-gate-qualification stays reachable
|
|
30
|
-
//
|
|
29
|
+
// 8. the moved check-gate-qualification stays reachable as the schedule/dispatch
|
|
30
|
+
// drift diagnostic it is since #144 (never a push, never release_gate), that
|
|
31
|
+
// diagnostic runs over the FULL floor, and the exact-SHA release oracle takes
|
|
32
|
+
// only a push run as its witness, refuses every required job that did not
|
|
33
|
+
// conclude success, and never reports the qualification body;
|
|
31
34
|
// 9. every gate a committed mutant NAMES also runs inside `check:full` (or says
|
|
32
35
|
// in its own prose why it deliberately does not) — a gate reachable only
|
|
33
36
|
// through qualification's control-pre puts a whole defect class behind the
|
|
@@ -44,7 +47,17 @@
|
|
|
44
47
|
|
|
45
48
|
import { strict as assert } from "node:assert";
|
|
46
49
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
47
|
-
import {
|
|
50
|
+
import {
|
|
51
|
+
existsSync,
|
|
52
|
+
globSync,
|
|
53
|
+
mkdirSync,
|
|
54
|
+
mkdtempSync,
|
|
55
|
+
readFileSync,
|
|
56
|
+
realpathSync,
|
|
57
|
+
rmSync,
|
|
58
|
+
symlinkSync,
|
|
59
|
+
writeFileSync,
|
|
60
|
+
} from "node:fs";
|
|
48
61
|
import { tmpdir } from "node:os";
|
|
49
62
|
import { basename, join } from "node:path";
|
|
50
63
|
import { fileURLToPath } from "node:url";
|
|
@@ -356,24 +369,19 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
356
369
|
// ===========================================================================
|
|
357
370
|
// 8. qualification scheduling topology — the subtraction has its own oracle.
|
|
358
371
|
// check-gate-qualification left the default check chains (operator
|
|
359
|
-
// inner-loop cost, 2026-08 subtraction)
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// the
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// times. Without this cell, deleting the CI line leaves every focused gate green
|
|
367
|
-
// while a cut quietly loses its discriminating-power step, and re-adding the
|
|
368
|
-
// release_gate block silently doubles every cut again.
|
|
372
|
+
// inner-loop cost, 2026-08 subtraction), and #132/#133 moved it off
|
|
373
|
+
// release_gate. #144 took it off every push and out of release acceptance: the
|
|
374
|
+
// whole body is a drift diagnostic that runs only on the weekly schedule or a
|
|
375
|
+
// `qualify=true` dispatch, and the release oracle reads only the push run's four
|
|
376
|
+
// required jobs. That is a gate/release contract: the step must stay REACHABLE on
|
|
377
|
+
// exactly those triggers, and must not silently return to the default chain, to
|
|
378
|
+
// release_gate, to a push, or to the release skill as an automatic dispatch.
|
|
369
379
|
//
|
|
370
|
-
//
|
|
371
|
-
// (absent from the default chain
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
// actually ran there (#103). Each carries its own replant — one mutant must
|
|
376
|
-
// never stand in for another.
|
|
380
|
+
// TWO claims, because each is an independent contract. 8a is REACHABILITY
|
|
381
|
+
// (absent from the default chain, from release_gate, from push and from the
|
|
382
|
+
// release skill; present exactly once in CI under its two triggers). 8b is what
|
|
383
|
+
// that diagnostic runs over (the FULL floor, before it), added by #70. Each
|
|
384
|
+
// carries its own replant — one mutant must never stand in for another.
|
|
377
385
|
// ===========================================================================
|
|
378
386
|
{
|
|
379
387
|
// The default chain is tiered (#70): `check` (core) and `check:full` compose the
|
|
@@ -391,7 +399,9 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
391
399
|
const runShQ = readFileSync(join(REPO_DIR, "run.sh"), "utf8");
|
|
392
400
|
const qualGateBody = runShQ.slice(runShQ.indexOf("release_gate() {"), runShQ.indexOf("# 5. Summary"));
|
|
393
401
|
const invocations = qualGateBody.split('bash "$self" check-gate-qualification').length - 1;
|
|
394
|
-
const
|
|
402
|
+
const qualStepAt = ciYml.indexOf("- run: ./run.sh check-gate-qualification");
|
|
403
|
+
const qualIf = qualStepAt === -1 ? "" : (ciYml.slice(qualStepAt).split("\n")[1] ?? "").trim();
|
|
404
|
+
const releaseSkill = readFileSync(join(REPO_DIR, ".claude/skills/entwurf-release/SKILL.md"), "utf8");
|
|
395
405
|
|
|
396
406
|
// One claim token, one assert (the qualification runner requires the exact-once
|
|
397
407
|
// signature); each broken axis names itself in the joined message.
|
|
@@ -403,32 +413,32 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
403
413
|
if (ciHits !== 1) holes.push(`the CI check job runs check-gate-qualification ${ciHits}x (need exactly once)`);
|
|
404
414
|
if (invocations !== 0)
|
|
405
415
|
holes.push(
|
|
406
|
-
`release_gate invokes check-gate-qualification ${invocations}x (need none: the
|
|
416
|
+
`release_gate invokes check-gate-qualification ${invocations}x (need none: the body is a schedule/dispatch diagnostic, #144)`,
|
|
407
417
|
);
|
|
408
418
|
if (
|
|
409
419
|
qualGateBody.includes("PASS check-gate-qualification") ||
|
|
410
420
|
qualGateBody.includes("FAIL check-gate-qualification")
|
|
411
421
|
)
|
|
412
422
|
holes.push("release_gate still counts a check-gate-qualification MUST row");
|
|
413
|
-
if (
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
)
|
|
417
|
-
)
|
|
418
|
-
|
|
423
|
+
if (qualIf !== "if: github.event_name == 'schedule' || inputs.qualify == true")
|
|
424
|
+
holes.push(
|
|
425
|
+
`the CI qualification step's trigger is \`${qualIf}\` (need the schedule or a qualify=true dispatch only)`,
|
|
426
|
+
);
|
|
427
|
+
if (ciYml.includes("qualify-decide")) holes.push("ci.yml still carries the retired push qualification filter step");
|
|
428
|
+
if (releaseSkill.includes("-f qualify=true"))
|
|
429
|
+
holes.push("the release skill still dispatches the qualification body (it is not release evidence, #144)");
|
|
419
430
|
assert.ok(
|
|
420
431
|
holes.length === 0,
|
|
421
432
|
"[QK:QUALIFICATION-SCHEDULING-REACHABLE] check-gate-qualification left the default check chains " +
|
|
422
|
-
"deliberately, so it must stay REACHABLE on the
|
|
423
|
-
"
|
|
424
|
-
`and
|
|
433
|
+
"deliberately, so it must stay REACHABLE on the triggers that own it now — exactly once in the CI check " +
|
|
434
|
+
"job, on the weekly schedule or a qualify=true dispatch only — and absent from the default chain, from " +
|
|
435
|
+
`release_gate, from every push and from the release skill. Broken: ${holes.join("; ")}`,
|
|
425
436
|
);
|
|
426
437
|
|
|
427
|
-
// 8b. WHAT the CI qualification step
|
|
428
|
-
// folded into the one above. #70 split the deterministic floor into tiers
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
// a floor no candidate ships on. This is ONE contract with one condition —
|
|
438
|
+
// 8b. WHAT the CI qualification step runs over — its own claim, not a branch
|
|
439
|
+
// folded into the one above. #70 split the deterministic floor into tiers:
|
|
440
|
+
// the diagnostic body on top of the ≤60s core would re-plant mutants over a
|
|
441
|
+
// floor no candidate ships on. This is ONE contract with one condition —
|
|
432
442
|
// the full floor is present in the CI check job AND qualification follows
|
|
433
443
|
// it — because a floor that is absent and a floor that runs afterwards
|
|
434
444
|
// break the same promise identically. The committed replant qualifies the
|
|
@@ -440,372 +450,175 @@ function runSubcommand(sub: string, env: Record<string, string | undefined>): {
|
|
|
440
450
|
ciFloorAt !== -1 && ciQualAt > ciFloorAt,
|
|
441
451
|
"[QK:CI-FULL-FLOOR-QUALIFIED] the CI check job owns the FULL deterministic floor (#70): it must run " +
|
|
442
452
|
"`pnpm run check:full` — not the ≤60s core — and check-gate-qualification must come AFTER it, so the " +
|
|
443
|
-
"
|
|
453
|
+
"diagnostic body (schedule or dispatch) re-plants over the floor a candidate actually ships on. The committed replant qualifies the " +
|
|
444
454
|
"downgrade/omission axis; ordering is directly asserted by this same oracle. " +
|
|
445
455
|
`Broken: check:full at index ${ciFloorAt}, qualification at index ${ciQualAt}.`,
|
|
446
456
|
);
|
|
447
|
-
|
|
448
|
-
// 8c. The RELEASE oracle requires that body to have actually run at the release
|
|
449
|
-
// SHA -- its own contract, not a branch of 8a. 8a is REACHABILITY (the step
|
|
450
|
-
// exists in the CI job and in release_gate); this is about what the release
|
|
451
|
-
// path ACCEPTS as evidence for one SHA. `verify-exact-ci.sh` read only three
|
|
452
|
-
// job names, and a job conclusion says nothing about whether the step inside
|
|
453
|
-
// it ran: the moment ci.yml can skip that step (#103 piece 2), a green oracle
|
|
454
|
-
// would certify a SHA whose qualification body never executed. That is the
|
|
455
|
-
// "green with no evidence" class this repo fails closed on, which is why the
|
|
456
|
-
// oracle gets the fourth axis BEFORE the skip exists.
|
|
457
|
-
//
|
|
458
|
-
// This is a TEXT oracle, the same grade as check-install-surface S7g. A
|
|
459
|
-
// behavioural oracle would need either a second file (S7a/S7g bind the
|
|
460
|
-
// release surface to one script read from the index) or an injectable
|
|
461
|
-
// RUN_JSON seam -- and that seam would be a way to launder release evidence
|
|
462
|
-
// past gh. Both cost more than the axis is worth here.
|
|
463
|
-
const ciOracle = readFileSync(join(REPO_DIR, ".claude/skills/entwurf-release/scripts/verify-exact-ci.sh"), "utf8");
|
|
464
|
-
const axis: string[] = [];
|
|
465
|
-
if (!ciOracle.includes('QUAL_JOB = "check"') || !ciOracle.includes('"Run ./run.sh check-gate-qualification"'))
|
|
466
|
-
axis.push("the oracle does not name the check job's check-gate-qualification step");
|
|
467
|
-
if (!ciOracle.includes('qual != "success"'))
|
|
468
|
-
axis.push("the oracle does not guard that step's conclusion against 'success'");
|
|
469
|
-
if (!ciOracle.includes('qual == "skipped"'))
|
|
470
|
-
axis.push("the oracle does not classify a SKIPPED body as a failure of its own");
|
|
471
|
-
assert.ok(
|
|
472
|
-
axis.length === 0,
|
|
473
|
-
"[QK:RELEASE-SHA-QUALIFIED-IN-CI] the exact-SHA CI oracle must require the qualification BODY to have run " +
|
|
474
|
-
"at the release SHA -- the `check` job's `Run ./run.sh check-gate-qualification` step concluding 'success', " +
|
|
475
|
-
"with skipped named as its own failure. Three green job names do not prove the step inside one of them " +
|
|
476
|
-
`executed. Broken: ${axis.join("; ")}`,
|
|
477
|
-
);
|
|
478
457
|
}
|
|
479
458
|
|
|
480
459
|
// ===========================================================================
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
//
|
|
460
|
+
// 8c'. The release oracle, driven, not read (#144). The shipped verify-exact-ci.sh
|
|
461
|
+
// runs unmodified in verify mode with a PATH holding only bash, python3 and a
|
|
462
|
+
// fake `gh`, so the mocked boundary is the external CLI and the script gains
|
|
463
|
+
// no input of its own (a RUN_JSON seam in the shipped script would be a way to
|
|
464
|
+
// launder release evidence past gh). The fake logs every call first, admits
|
|
465
|
+
// only the four argv shapes verify mode sends, and refuses anything else with
|
|
466
|
+
// its own code and marker. THREE claims, asserted in this order so each
|
|
467
|
+
// mutant dies on its own:
|
|
468
|
+
// - the witness is a PUSH run: the one `run list` call carries
|
|
469
|
+
// `--event push` and a jq keeping only push runs of this SHA. That is the
|
|
470
|
+
// argv the script SENDS; the fake does not evaluate the jq, and GitHub's
|
|
471
|
+
// own filtering is not observed here;
|
|
472
|
+
// - every required job that did not conclude success is refused by name
|
|
473
|
+
// (absent, failure, skipped, cancelled, null);
|
|
474
|
+
// - the qualification body is not a release axis: the oracle passes whether
|
|
475
|
+
// the check job's body step is skipped, absent or green, and never prints
|
|
476
|
+
// it.
|
|
477
|
+
// `wait` mode and newest-run choice are outside this cell. The four job names
|
|
478
|
+
// are this gate's own literals, never read from the script. Each negative
|
|
479
|
+
// cell changes one fact and nothing else.
|
|
491
480
|
// ===========================================================================
|
|
492
481
|
{
|
|
493
|
-
const
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
assert.ok(paths.size > 50, `read only ${paths.size} mutant paths from the manifests`);
|
|
505
|
-
const uncovered = [...paths].filter((path) => {
|
|
506
|
-
const out = execFileSync("bash", [decider, "--files-from", "-"], {
|
|
507
|
-
cwd: REPO_DIR,
|
|
508
|
-
encoding: "utf8",
|
|
509
|
-
input: `${path}\n`,
|
|
510
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
511
|
-
});
|
|
512
|
-
return out.trim() !== "run_body=true";
|
|
513
|
-
});
|
|
514
|
-
assert.ok(
|
|
515
|
-
uncovered.length === 0,
|
|
516
|
-
"[QK:QUALIFY-FILTER-COVERS-SUBJECTS] the CI qualification filter must run the body for a change to ANY " +
|
|
517
|
-
"committed mutant subject or signature source — it derives that set from scripts/mutants/*.json for " +
|
|
518
|
-
"exactly this reason, so a path it cannot see is a claim that silently stops being re-proven in CI. " +
|
|
519
|
-
`Uncovered (${uncovered.length} of ${paths.size}): ${uncovered.slice(0, 8).join(", ")}`,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// ===========================================================================
|
|
524
|
-
// 8e. The filter still runs the body for every RED the body has ever produced
|
|
525
|
-
//
|
|
526
|
-
// Five reds in 549 runs (#99 stage-2). The first reading of them used the
|
|
527
|
-
// tip COMMIT and concluded the filter would have missed four; replaying the
|
|
528
|
-
// real two-dot push range — what GitHub actually compares — showed all five
|
|
529
|
-
// hit. That reversal is the whole evidentiary basis for this filter.
|
|
530
|
-
//
|
|
531
|
-
// It is asserted from RECORDED file lists, not from live history, and that
|
|
532
|
-
// is a constraint rather than a convenience: check-gate-qualification runs
|
|
533
|
-
// every gate inside a snapshot with its own fresh git baseline, where these
|
|
534
|
-
// 2026-07/08 commits do not exist. A cell that read history there would be
|
|
535
|
-
// CONTROL-RED for the whole lane — measured, not predicted (this cell did
|
|
536
|
-
// exactly that on 2026-09-06 and cost the release-gate lane its 17 kills).
|
|
537
|
-
// The fixture carries what history said, measured once, in a repo where the
|
|
538
|
-
// objects are present.
|
|
539
|
-
//
|
|
540
|
-
// It has its own replant, and the reason is the SHAPE of the input: 8d feeds
|
|
541
|
-
// ONE path per call, this feeds a whole push at once. A matcher that only
|
|
542
|
-
// looked at the first entry would leave 8d green — every single-file call is
|
|
543
|
-
// its own first entry — while every one of these five pushes opens with a
|
|
544
|
-
// non-surface path (DELIVERY.md, AGENTS.md, NEXT.md, AGENTS.md,
|
|
545
|
-
// demo/demo-baseline.sh). Invisible to 8d, fatal here.
|
|
546
|
-
//
|
|
547
|
-
// The two-dot READING itself is proven separately, below.
|
|
548
|
-
// ===========================================================================
|
|
549
|
-
{
|
|
550
|
-
const decider = join(REPO_DIR, "scripts/ci-qualify-decide.sh");
|
|
551
|
-
const fixture = JSON.parse(readFileSync(join(REPO_DIR, "scripts/fixtures/qualify-replay.json"), "utf8")) as {
|
|
552
|
-
runs: { runId: string; before: string; head: string; files: string[]; tipOnlyFiles: string[] }[];
|
|
553
|
-
};
|
|
554
|
-
assert.equal(fixture.runs.length, 5, "the replay fixture holds all five historical qualification reds");
|
|
482
|
+
const oracle = join(REPO_DIR, ".claude/skills/entwurf-release/scripts/verify-exact-ci.sh");
|
|
483
|
+
const required = ["check", "install-surface", "artifact-consumer", "macos-install-surface"];
|
|
484
|
+
const qualStep = "Run ./run.sh check-gate-qualification";
|
|
485
|
+
const sha = "0123456789abcdef0123456789abcdef01234567";
|
|
486
|
+
const unexpected = "UNEXPECTED-GH-ARGV";
|
|
487
|
+
const tmp = mkdtempSync(join(tmpdir(), "entwurf-exact-ci-"));
|
|
488
|
+
const bin = join(tmp, "bin");
|
|
489
|
+
const resolve = (tool: string) =>
|
|
490
|
+
realpathSync(execFileSync("bash", ["-c", `command -v ${tool}`], { encoding: "utf8" }).trim());
|
|
555
491
|
const wrong: string[] = [];
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
const out = execFileSync("bash", [decider, "--files-from", "-"], {
|
|
559
|
-
cwd: REPO_DIR,
|
|
560
|
-
encoding: "utf8",
|
|
561
|
-
input: `${run.files.join("\n")}\n`,
|
|
562
|
-
stdio: ["pipe", "pipe", "ignore"],
|
|
563
|
-
}).trim();
|
|
564
|
-
if (out !== "run_body=true") wrong.push(`run ${run.runId} (${run.files.length} files) decided ${out}`);
|
|
565
|
-
}
|
|
566
|
-
assert.ok(
|
|
567
|
-
wrong.length === 0,
|
|
568
|
-
"[QK:QUALIFY-FILTER-REPLAYS-PAST-CATCHES] every qualification RED in this repo's CI history must still " +
|
|
569
|
-
"run the body under the filter, over the two-dot push range GitHub compares — the measurement that " +
|
|
570
|
-
"overturned the tip-commit reading and justified filtering at all. This reads a whole push at once, " +
|
|
571
|
-
"which 8d cannot: each of these five opens with a non-surface path, so a matcher that stopped after " +
|
|
572
|
-
"the first entry would pass every single-file check 8d makes and fail here. " +
|
|
573
|
-
`Broken: ${wrong.join("; ")}`,
|
|
574
|
-
);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// ===========================================================================
|
|
578
|
-
// 8f. The filter reads the two-dot PUSH RANGE, not the tip commit
|
|
579
|
-
//
|
|
580
|
-
// The fixture above proves the matcher's verdict on recorded file lists; it
|
|
581
|
-
// cannot prove which git range produced them, because it calls no git. This
|
|
582
|
-
// does, hermetically: a throwaway repo shaped like the real defect — a push
|
|
583
|
-
// of two commits whose TIP is docs-only while the commit under it touched a
|
|
584
|
-
// mutant subject. The two-dot range sees the code; the tip alone does not.
|
|
585
|
-
// That is the exact misreading #99 corrected, and it is what four of the
|
|
586
|
-
// five recorded pushes look like when read the wrong way.
|
|
587
|
-
//
|
|
588
|
-
// Hermetic on purpose: no repo history, so it runs identically inside the
|
|
589
|
-
// qualification snapshot.
|
|
590
|
-
// ===========================================================================
|
|
591
|
-
{
|
|
592
|
-
const decider = join(REPO_DIR, "scripts/ci-qualify-decide.sh");
|
|
593
|
-
const tmp = mkdtempSync(join(tmpdir(), "entwurf-qualify-range-"));
|
|
594
|
-
// core.hooksPath is set globally on this operator's machine; a fixture repo
|
|
595
|
-
// must not run their hooks.
|
|
596
|
-
const git = (...args: string[]) =>
|
|
597
|
-
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "user.email=g@e", "-c", "user.name=g", ...args], {
|
|
598
|
-
cwd: tmp,
|
|
599
|
-
stdio: "ignore",
|
|
600
|
-
});
|
|
492
|
+
const wrongWitness: string[] = [];
|
|
493
|
+
const wrongNoBody: string[] = [];
|
|
601
494
|
try {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
copyFileSync(decider, join(tmp, "scripts/ci-qualify-decide.sh"));
|
|
495
|
+
mkdirSync(bin);
|
|
496
|
+
mkdirSync(join(tmp, "home"));
|
|
497
|
+
symlinkSync(resolve("bash"), join(bin, "bash"));
|
|
498
|
+
symlinkSync(resolve("python3"), join(bin, "python3"));
|
|
499
|
+
const runList = `run list --repo fixture/entwurf --workflow ci.yml --commit ${sha} --event push --limit 10 --json databaseId,headSha,createdAt,event --jq`;
|
|
500
|
+
const pushOnlyJq = `map(select(.headSha == "${sha}" and .event == "push")) | sort_by(.createdAt) | reverse | .[0].databaseId // empty`;
|
|
609
501
|
writeFileSync(
|
|
610
|
-
join(
|
|
611
|
-
|
|
612
|
-
{
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
"
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
git("commit", "-qm", "code commit (mid-push)");
|
|
628
|
-
writeFileSync(join(tmp, "README.md"), "docs only\n");
|
|
629
|
-
git("add", "-A");
|
|
630
|
-
git("commit", "-qm", "docs commit (tip)");
|
|
631
|
-
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: tmp, encoding: "utf8" }).trim();
|
|
632
|
-
const decide = (from: string) =>
|
|
633
|
-
execFileSync("bash", [join(tmp, "scripts/ci-qualify-decide.sh"), from, head], {
|
|
634
|
-
cwd: tmp,
|
|
635
|
-
encoding: "utf8",
|
|
636
|
-
env: { ...process.env, CI_EVENT_NAME: "push", CI_FORCED: "false" },
|
|
637
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
638
|
-
}).trim();
|
|
639
|
-
const overRange = decide(base);
|
|
640
|
-
const overTip = decide(`${head}~1`);
|
|
641
|
-
assert.ok(
|
|
642
|
-
overRange === "run_body=true" && overTip === "run_body=false",
|
|
643
|
-
"[QK:QUALIFY-FILTER-READS-PUSH-RANGE] the filter must diff the whole two-dot push range, not the tip " +
|
|
644
|
-
"commit: a push whose tip is docs-only can still carry a mutant subject underneath it, which is how " +
|
|
645
|
-
"four of the five historical reds look like docs pushes when read tip-first. " +
|
|
646
|
-
`Broken: range=${overRange}, tip-only=${overTip} (tip-only must be false, or this fixture proves nothing).`,
|
|
502
|
+
join(bin, "gh"),
|
|
503
|
+
[
|
|
504
|
+
`#!${join(bin, "bash")}`,
|
|
505
|
+
'd="${0%/*}/.."',
|
|
506
|
+
`printf '%s\\n' "$*" >> "$d/gh.log"`,
|
|
507
|
+
'case "$*" in',
|
|
508
|
+
' "auth status") exit 0 ;;',
|
|
509
|
+
" \"repo view --json nameWithOwner --jq .nameWithOwner\") printf 'fixture/entwurf\\n'; exit 0 ;;",
|
|
510
|
+
' "run view 4242 --repo fixture/entwurf --json databaseId,headSha,status,conclusion,url,event,jobs")',
|
|
511
|
+
` printf '%s\\n' "$(<"$d/run.json")"; exit 0 ;;`,
|
|
512
|
+
"esac",
|
|
513
|
+
`if [ "$#" -eq 16 ] && [ "\${*:1:15}" = "${runList}" ]; then printf '4242\\n'; exit 0; fi`,
|
|
514
|
+
`printf '${unexpected}: %s\\n' "$*" >&2`,
|
|
515
|
+
"exit 86",
|
|
516
|
+
"",
|
|
517
|
+
].join("\n"),
|
|
518
|
+
{ mode: 0o755 },
|
|
647
519
|
);
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
// code/signatures/machinery remain file-level. Hermetic like 8f: real history is NOT
|
|
656
|
-
// in a qualification snapshot.
|
|
657
|
-
// ==========================================================================
|
|
658
|
-
{
|
|
659
|
-
const tmp = mkdtempSync(join(tmpdir(), "entwurf-qualify-hunks-"));
|
|
660
|
-
const git = (...args: string[]) =>
|
|
661
|
-
execFileSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "user.email=g@e", "-c", "user.name=g", ...args], {
|
|
662
|
-
cwd: tmp,
|
|
663
|
-
stdio: "ignore",
|
|
664
|
-
});
|
|
665
|
-
const head = () => execFileSync("git", ["rev-parse", "HEAD"], { cwd: tmp, encoding: "utf8" }).trim();
|
|
666
|
-
const data = 'version = "1"\n[[panes]]\ncommand = ["node", "build.mjs"]\n# outside\n';
|
|
667
|
-
const wrong: string[] = [];
|
|
668
|
-
try {
|
|
669
|
-
git("init", "-q", "-b", "main");
|
|
670
|
-
mkdirSync(join(tmp, "scripts/mutants"), { recursive: true });
|
|
671
|
-
mkdirSync(join(tmp, "fixture"));
|
|
672
|
-
mkdirSync(join(tmp, "scripts/fixtures"));
|
|
673
|
-
copyFileSync(join(REPO_DIR, "scripts/ci-qualify-decide.sh"), join(tmp, "scripts/ci-qualify-decide.sh"));
|
|
674
|
-
const manifest = JSON.stringify({
|
|
675
|
-
mutants: [
|
|
676
|
-
{ subject: "fixture/data.toml", find: ["[[panes]]"] },
|
|
677
|
-
{ subject: "fixture/data.toml", find: ['command = ["node", "build.mjs"]'] },
|
|
678
|
-
{ subject: "fixture/data.toml", find: ["[[panes]]", 'command = ["node", "build.mjs"]'] },
|
|
679
|
-
{ subject: "fixture/code.ts", find: ["return 1"], signatureSource: "fixture/signature.md" },
|
|
680
|
-
{ subject: "fixture/signature.md", find: ["signature"] },
|
|
681
|
-
{ subject: "fixture/joined.toml", find: ["alpha = 1", "beta = 2"] },
|
|
682
|
-
{ subject: "fixture/doc.md", find: ["Legacy rows are documented: kept for history."] },
|
|
683
|
-
{ subject: "fixture/reg.json", find: ['"mode": "self-fetch",'] },
|
|
684
|
-
{ subject: "scripts/fixtures/pin.json", find: ['"pinned": 1'] },
|
|
685
|
-
{ subject: "scripts/ci-qualify-decide.sh", find: ["set -euo pipefail"] },
|
|
686
|
-
],
|
|
520
|
+
// The check job carries the body step the way a push run renders it since #144:
|
|
521
|
+
// present and skipped. The no-body cells below vary only that step.
|
|
522
|
+
const job = (name: string, body: string | null = "skipped") => ({
|
|
523
|
+
name,
|
|
524
|
+
status: "completed",
|
|
525
|
+
conclusion: "success" as string | null,
|
|
526
|
+
steps: name === "check" && body !== null ? [{ name: qualStep, status: "completed", conclusion: body }] : [],
|
|
687
527
|
});
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const r = spawnSync("bash", [join(tmp, "scripts/ci-qualify-decide.sh"), before, head()], {
|
|
704
|
-
cwd: tmp,
|
|
528
|
+
const drive = (jobs: ReturnType<typeof job>[]) => {
|
|
529
|
+
writeFileSync(
|
|
530
|
+
join(tmp, "run.json"),
|
|
531
|
+
JSON.stringify({
|
|
532
|
+
databaseId: 4242,
|
|
533
|
+
headSha: sha,
|
|
534
|
+
status: "completed",
|
|
535
|
+
conclusion: "success",
|
|
536
|
+
url: "https://example.invalid/runs/4242",
|
|
537
|
+
event: "push",
|
|
538
|
+
jobs,
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
rmSync(join(tmp, "gh.log"), { force: true });
|
|
542
|
+
const r = spawnSync(join(bin, "bash"), [oracle, sha, "verify"], {
|
|
705
543
|
encoding: "utf8",
|
|
706
|
-
env: {
|
|
544
|
+
env: { PATH: bin, HOME: join(tmp, "home") },
|
|
545
|
+
timeout: 20_000,
|
|
707
546
|
});
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const commit = () => {
|
|
712
|
-
git("add", "-A");
|
|
713
|
-
git("commit", "-qm", "cell");
|
|
547
|
+
// No log means no call reached the fake; the cell names that through its own fields.
|
|
548
|
+
const log = existsSync(join(tmp, "gh.log")) ? readFileSync(join(tmp, "gh.log"), "utf8") : "";
|
|
549
|
+
return { ...r, log };
|
|
714
550
|
};
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
);
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
551
|
+
|
|
552
|
+
const control = drive(required.map((name) => job(name)));
|
|
553
|
+
const passLines = [
|
|
554
|
+
`exact-ci: PASS sha=${sha} run=4242 event=push`,
|
|
555
|
+
...required.map((name) => `exact-ci: job ${name}=success`),
|
|
556
|
+
"exact-ci: qualification BODY is not a release axis (#144)",
|
|
557
|
+
];
|
|
558
|
+
const calls = control.log.trim().split("\n");
|
|
559
|
+
const listCalls = calls.filter((call) => call.startsWith("run list "));
|
|
560
|
+
if (listCalls.length !== 1 || listCalls[0] !== `${runList} ${pushOnlyJq}`)
|
|
561
|
+
wrongWitness.push(`run list calls: ${JSON.stringify(listCalls)}`);
|
|
562
|
+
if (
|
|
563
|
+
control.status !== 0 ||
|
|
564
|
+
passLines.some((line) => !control.stdout.includes(line)) ||
|
|
565
|
+
control.stderr.includes(unexpected) ||
|
|
566
|
+
calls.length !== 4 ||
|
|
567
|
+
calls.some((call) => call.startsWith("run watch"))
|
|
568
|
+
)
|
|
569
|
+
wrong.push(`control: status=${control.status}, out=${control.stdout.trim()}, err=${control.stderr.trim()}`);
|
|
570
|
+
|
|
571
|
+
for (const name of required) {
|
|
572
|
+
for (const state of ["absent", "failure", "skipped", "cancelled", "null"]) {
|
|
573
|
+
const jobs = required.map((n) => job(n)).filter((j) => state !== "absent" || j.name !== name);
|
|
574
|
+
for (const j of jobs) if (j.name === name) j.conclusion = state === "null" ? null : state;
|
|
575
|
+
const shown = state === "absent" || state === "null" ? "None" : `'${state}'`;
|
|
576
|
+
const diagnostic = `job '${name}' conclusion=${shown}, expected 'success'`;
|
|
577
|
+
const r = drive(jobs);
|
|
578
|
+
if (
|
|
579
|
+
r.status === 0 ||
|
|
580
|
+
!r.stderr.includes("ABORT: exact-SHA CI contract failed:") ||
|
|
581
|
+
!r.stderr.includes(diagnostic) ||
|
|
582
|
+
r.stdout.includes("exact-ci: PASS") ||
|
|
583
|
+
r.stderr.includes(unexpected)
|
|
584
|
+
)
|
|
585
|
+
wrong.push(`${name}=${state}: status=${r.status}, want "${diagnostic}", err=${r.stderr.trim()}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// The body step skipped (the control), absent, or green: the oracle passes on
|
|
590
|
+
// the four jobs alone and its output never names the body.
|
|
591
|
+
const bodyCells: [string, { status: number | null; stdout: string }][] = [["skipped", control]];
|
|
592
|
+
bodyCells.push(["absent", drive(required.map((name) => job(name, null)))]);
|
|
593
|
+
bodyCells.push(["success", drive(required.map((name) => job(name, "success")))]);
|
|
594
|
+
for (const [body, r] of bodyCells) {
|
|
595
|
+
if (r.status !== 0 || !r.stdout.includes("exact-ci: PASS") || r.stdout.includes("check-gate-qualification"))
|
|
596
|
+
wrongNoBody.push(`body step ${body}: status=${r.status}, out=${r.stdout.trim()}`);
|
|
737
597
|
}
|
|
738
|
-
git("rm", "fixture/data.toml");
|
|
739
|
-
commit();
|
|
740
|
-
check("deleted subject", true);
|
|
741
|
-
reset();
|
|
742
|
-
git("mv", "fixture/data.toml", "fixture/renamed.toml");
|
|
743
|
-
commit();
|
|
744
|
-
check("renamed subject", true);
|
|
745
|
-
reset();
|
|
746
|
-
writeFileSync(join(tmp, "fixture/code.ts"), "// outside changed\nfunction f() { return 1; }\n");
|
|
747
|
-
commit();
|
|
748
|
-
check("code outside find remains file-level", true);
|
|
749
|
-
reset();
|
|
750
|
-
writeFileSync(join(tmp, "fixture/signature.md"), "signature\n# outside changed\n");
|
|
751
|
-
commit();
|
|
752
|
-
check("signature also data subject remains file-level", true);
|
|
753
|
-
reset();
|
|
754
|
-
writeFileSync(join(tmp, "fixture/joined.toml"), "version = 1\nalpha = 1\nbeta = 2 # changed\n");
|
|
755
|
-
commit();
|
|
756
|
-
check("only multiline find, second line touched", true);
|
|
757
|
-
reset();
|
|
758
|
-
writeFileSync(join(tmp, "scripts/fixtures/pin.json"), '{\n"version": 2,\n"pinned": 1\n}\n');
|
|
759
|
-
commit();
|
|
760
|
-
check("machinery also data subject remains file-level", true);
|
|
761
|
-
reset();
|
|
762
|
-
writeFileSync(
|
|
763
|
-
join(tmp, "scripts/ci-qualify-decide.sh"),
|
|
764
|
-
`${readFileSync(join(tmp, "scripts/ci-qualify-decide.sh"), "utf8")}\n# outside find\n`,
|
|
765
|
-
);
|
|
766
|
-
commit();
|
|
767
|
-
check("machinery also subject remains file-level", true);
|
|
768
|
-
reset();
|
|
769
|
-
// Outside an untouched exact-once span, a shorter presence literal (measured on DELIVERY.md)
|
|
770
|
-
// or a later JSON duplicate key (JSON.parse: last wins) can re-satisfy the oracle.
|
|
771
|
-
writeFileSync(join(tmp, "fixture/doc.md"), `${doc}Legacy rows are documented\n`);
|
|
772
|
-
commit();
|
|
773
|
-
check("markdown restatement outside find", true, "conservative:", base, nonToml);
|
|
774
|
-
reset();
|
|
775
|
-
writeFileSync(
|
|
776
|
-
join(tmp, "fixture/reg.json"),
|
|
777
|
-
reg.replace("6\n}\n}\n", '6\n},\n"copilot": { "level": 6, "mode": "self-fetch" }\n}\n'),
|
|
778
|
-
);
|
|
779
|
-
commit();
|
|
780
|
-
check("json duplicate key outside find", true, "conservative:", base, nonToml);
|
|
781
|
-
reset();
|
|
782
|
-
// The first quoted name must still fail open after a >pipe-buffer list:
|
|
783
|
-
// printf | grep -q can lose its match to SIGPIPE under pipefail.
|
|
784
|
-
writeFileSync(join(tmp, '000"quoted'), "x\n");
|
|
785
|
-
for (let i = 0; i < 768; i++) writeFileSync(join(tmp, "fixture", `${i}_${"x".repeat(180)}.txt`), "x\n");
|
|
786
|
-
commit();
|
|
787
|
-
check("large quoted changed-name list", true, "quoted changed path cannot be read honestly");
|
|
788
|
-
reset();
|
|
789
|
-
check("unreadable range", true, "fail-open 4", "f".repeat(40));
|
|
790
|
-
git("rm", "scripts/mutants/fixture.json");
|
|
791
|
-
commit();
|
|
792
|
-
check("missing manifests", true);
|
|
793
|
-
assert.ok(
|
|
794
|
-
wrong.length === 0,
|
|
795
|
-
"[QK:QUALIFY-FILTER-SUBJECT-HUNKS] only untouched, unmoved, exact-once TOML find ranges may skip; " +
|
|
796
|
-
"unknown ranges run, and code/signature/machinery retain file-level protection. " +
|
|
797
|
-
wrong.join("; "),
|
|
798
|
-
);
|
|
799
|
-
assert.ok(
|
|
800
|
-
nonToml.length === 0,
|
|
801
|
-
"[QK:QUALIFY-FILTER-TOML-ONLY-REFINEMENT] Markdown and JSON subjects stay file-level: an untouched exact-once " +
|
|
802
|
-
"find does not pin kill-power when a shorter presence literal or a later duplicate JSON key outside it can " +
|
|
803
|
-
"re-satisfy the oracle. " +
|
|
804
|
-
nonToml.join("; "),
|
|
805
|
-
);
|
|
806
598
|
} finally {
|
|
807
599
|
rmSync(tmp, { recursive: true, force: true });
|
|
808
600
|
}
|
|
601
|
+
assert.ok(
|
|
602
|
+
wrongWitness.length === 0,
|
|
603
|
+
"[QK:RELEASE-WITNESS-IS-PUSH-RUN] the exact-SHA CI oracle must ask for the PUSH run of this SHA only: one " +
|
|
604
|
+
"`run list` call carrying `--event push` and a jq that keeps only push runs at this headSha. A " +
|
|
605
|
+
"workflow_dispatch run is the optional qualification diagnostic, never release evidence; admitted, a " +
|
|
606
|
+
`running or red dispatch would become the run judged. Broken: ${wrongWitness.join("; ")}`,
|
|
607
|
+
);
|
|
608
|
+
assert.ok(
|
|
609
|
+
wrong.length === 0,
|
|
610
|
+
"[QK:RELEASE-ORACLE-REFUSES-NON-SUCCESS-JOB] the exact-SHA CI oracle must accept a run whose four required " +
|
|
611
|
+
"jobs concluded success and refuse it, naming the job, when any one of them is absent, failed, skipped, " +
|
|
612
|
+
"cancelled or unconcluded -- a release SHA is certified only by success, never by the absence of failure. " +
|
|
613
|
+
`Broken:\n ${wrong.join("\n ")}`,
|
|
614
|
+
);
|
|
615
|
+
assert.ok(
|
|
616
|
+
wrongNoBody.length === 0,
|
|
617
|
+
"[QK:RELEASE-ORACLE-CLAIMS-NO-BODY] the qualification body is not a release axis (#144): the exact-SHA CI " +
|
|
618
|
+
"oracle must pass on the four required jobs whether the check job's body step is skipped, absent or green, " +
|
|
619
|
+
"and must never print it, since a PASS that names the body would read as kill-power evidence the release " +
|
|
620
|
+
`did not measure. Broken:\n ${wrongNoBody.join("\n ")}`,
|
|
621
|
+
);
|
|
809
622
|
}
|
|
810
623
|
|
|
811
624
|
// ===========================================================================
|
|
@@ -1255,11 +1068,11 @@ console.log(
|
|
|
1255
1068
|
"observed to propagate the code: a smoke with LIVE unset (through run_ts, with its operator-readable marker) and " +
|
|
1256
1069
|
"a run.sh wrapper declining its own prerequisite (including the measured LIVE=1 no-cortex-connection cell); and every " +
|
|
1257
1070
|
"LIVE smoke is either wired into release_gate or excluded by a sentence the docs still carry; and the moved " +
|
|
1258
|
-
"check-gate-qualification stays reachable
|
|
1259
|
-
"
|
|
1260
|
-
"while the exact-SHA release oracle
|
|
1261
|
-
"
|
|
1262
|
-
"
|
|
1071
|
+
"check-gate-qualification stays reachable only as the schedule/dispatch drift diagnostic (absent from the " +
|
|
1072
|
+
"default chain, from release_gate, from every push and from the release skill; exactly once in CI) over the " +
|
|
1073
|
+
"FULL floor, which runs before it, while the exact-SHA release oracle, driven through a fake gh, asks only for " +
|
|
1074
|
+
"the push run, refuses every required job that did not conclude success, and passes without ever naming the " +
|
|
1075
|
+
"qualification body; and " +
|
|
1263
1076
|
"every gate a committed mutant names is itself inside check:full or states its exclusion in prose an operator " +
|
|
1264
1077
|
"reads; and the CI push trigger is filtered to branch refs, so a release tag creates no duplicate run; and " +
|
|
1265
1078
|
"Pi -> visible Codex -> visible Pi first-admission callback/addressed/outbound acceptance is a release MUST " +
|