From 65eb0ce466171f4b866e5820b1aa1ed4ca886a81 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 05:11:10 +0000 Subject: [PATCH] fix(ci): count speculative-stack inheritance as one hit in merge-queue triage The merge-queue-triage workflow already parsed the queue branch name and threw away the half that matters: `/^gh-readonly-queue\/.+\/pr-(\d+)-[0-9a-f]{40}$/` matched the 40-hex base commit without capturing it. That sha is the commit the queue built the PR on top of, so under GitHub's speculative stacking a build whose base IS another victim's queue head contains that victim's tree by construction. Without it, a single deterministic break ejects every PR queued behind it and the anchor issue reports the queue depth as "N distinct pull requests" -- loudest in exactly the case carrying the least information. Capture the sha, index every merge_group run already fetched for the 24h counts by `runId -> {base, head}`, and union victims whose base equals another's head. Zero additional API calls: `recent` and `run.head_sha` were both already in hand. The trigger stays `a.prs.size >= 2`. What changes is the reporting -- the anchor now says "3 pull requests ... 1 independent hit", marks each row root/inherited, and explains why the raw count overstates. A victim whose run is not in the window index stays its own group: "cannot prove downstream" reads as INDEPENDENT, never as same-stack. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- .github/workflows/merge-queue-triage.yml | 190 +++++++++++++++++-- scripts/check-merge-queue-triage-outcome.mjs | 166 ++++++++++++++++ 2 files changed, 340 insertions(+), 16 deletions(-) diff --git a/.github/workflows/merge-queue-triage.yml b/.github/workflows/merge-queue-triage.yml index f8567ec772..4afd00800e 100644 --- a/.github/workflows/merge-queue-triage.yml +++ b/.github/workflows/merge-queue-triage.yml @@ -143,8 +143,19 @@ jobs: const run = context.payload.workflow_run; const { owner, repo } = context.repo; - // Queue branches are named gh-readonly-queue//pr--. - const m = /^gh-readonly-queue\/.+\/pr-(\d+)-[0-9a-f]{40}$/.exec(run.head_branch ?? ''); + // Queue branches are named gh-readonly-queue//pr--, and + // BOTH captures are load-bearing. The trailing sha is not decoration: + // it is the commit the queue built this PR ON TOP OF, so it names the + // PR's position in GitHub's speculative stack. Reading it is what lets + // limb ② tell "another PR hit this too" from "another PR inherited this + // one's tree" — see `stackEdges` below. It was matched but discarded + // until 2026-08-29, which is why the victim counts read as flake + // evidence when they were measuring queue depth. + // + // The base-branch segment is `.+` rather than `[^/]+` because a base + // branch may itself contain slashes (`release/v5`). + const QUEUE_REF = /^gh-readonly-queue\/.+\/pr-(\d+)-([0-9a-f]{40})$/; + const m = QUEUE_REF.exec(run.head_branch ?? ''); if (!m) { core.info(`head_branch '${run.head_branch}' is not a merge-queue branch — nothing to do.`); return; @@ -324,6 +335,97 @@ jobs: r.conclusion === 'failure' && (r.head_branch ?? '').includes(`/pr-${prNumber}-`)).length; const queueFailures24h = ciRuns.filter((r) => r.conclusion === 'failure').length; + // runId -> { base, head } for every merge_group run in the window, + // plus this one. `recent` is ALREADY in hand for the two counts above, + // and a run object already carries `head_branch` and `head_sha` — so + // the speculative stack costs ZERO additional API calls. That is the + // whole reason this discriminator is affordable in a job whose header + // calls itself rate-limit-shaped. + // + // `recent` is deliberately NOT narrowed to this workflow_id here: the + // sightings below are keyed by run id, and a wider index can only + // resolve more of them. + const stackEdges = new Map(); + const noteRun = (r) => { + const q = QUEUE_REF.exec(r?.head_branch ?? ''); + // A head_sha that is not a full sha cannot be compared to a base by + // equality, and an equality that silently never holds is the same + // failure as no data at all — so it is left OUT of the index rather + // than stored as something a comparison would quietly reject. + if (!q || !/^[0-9a-f]{40}$/.test(String(r?.head_sha ?? ''))) return; + stackEdges.set(String(r.id), { base: q[2], head: String(r.head_sha) }); + }; + for (const r of recent) noteRun(r); + noteRun(run); + + /** + * Partition a key's victim PRs into speculative STACKS. + * + * Under speculative stacking GitHub builds each queued PR on top of + * the previous entry's queue head, so a build whose BASE commit IS + * another victim's queue HEAD contains that victim's tree by + * construction. One deterministic break therefore ejects everyone + * behind it, and the raw victim count measures QUEUE DEPTH. + * + * ⚠️ The test is full-sha equality in the strict direction: one + * victim's base == another victim's head. ⛔ Never a base PREFIX and + * ⛔ never time ordering — both would fuse genuinely independent hits + * into one stack, trading this limb's false positive for a false + * negative, which is strictly worse. The count exists to be true. + * + * A victim whose runs are not in the index (a run older than the + * window, or a ref that is not a queue ref) is left as its own group: + * "cannot prove downstream" must read as INDEPENDENT, never as + * same-stack. `unresolved` reports how many of those there were, so a + * partial index cannot pass as a complete one. + */ + const stacksFor = (byPr) => { + const parent = new Map([...byPr.keys()].map((p) => [p, p])); + const find = (x) => { while (parent.get(x) !== x) x = parent.get(x); return x; }; + const union = (a, b) => { const ra = find(a); const rb = find(b); if (ra !== rb) parent.set(ra, rb); }; + + // head sha -> the victim PRs that PRODUCED it. + const producers = new Map(); + for (const [pr, runs] of byPr) { + for (const id of runs) { + const e = stackEdges.get(String(id)); + if (!e) continue; + if (!producers.has(e.head)) producers.set(e.head, new Set()); + producers.get(e.head).add(pr); + } + } + + const inherited = new Set(); + let unresolved = 0; + for (const [pr, runs] of byPr) { + let resolved = false; + for (const id of runs) { + const e = stackEdges.get(String(id)); + if (!e) continue; + resolved = true; + for (const upstream of producers.get(e.base) ?? []) { + if (upstream === pr) continue; + inherited.add(pr); + union(pr, upstream); + } + } + if (!resolved) unresolved++; + } + + const groups = new Map(); + for (const pr of byPr.keys()) { + const root = find(pr); + if (!groups.has(root)) groups.set(root, []); + groups.get(root).push(pr); + } + const ordered = [...groups.values()] + .map((g) => g.sort((x, y) => x - y)) + .sort((a, b) => a[0] - b[0]); + const stackOf = new Map(); + ordered.forEach((g, i) => { for (const pr of g) stackOf.set(pr, { index: i + 1, size: g.length }); }); + return { groups: ordered, stackOf, inherited, unresolved }; + }; + // ── Limb ②: cross-PR signature aggregation ───────────────────── // // The ledger is this workflow's OWN comments. Every triage comment @@ -374,11 +476,39 @@ jobs: // Only keys THIS ejection actually hit are aggregated: an ejection // must not re-file anchors for signatures it had nothing to do with. + // ⛔ The trigger stays `a.prs.size >= 2`. Stack inheritance changes + // what this limb REPORTS, never what it notices: raising the bar to + // "2 independent hits" would silence a real deterministic break that + // has already eaten the whole queue behind it, which is precisely the + // case a reader most needs to see. Honest counting, not a quieter one. const aggregated = runKeys - .map((key) => ({ key, prs: sightings.get(key) ?? new Map() })) + .map((key) => { + const prs = sightings.get(key) ?? new Map(); + return { key, prs, ...stacksFor(prs) }; + }) .filter((a) => a.prs.size >= 2) .sort((a, b) => b.prs.size - a.prs.size); + /** + * The one sentence four call sites need: how many PRs, and how many of + * them are actually independent. + * + * Single-sourced because the four `anchorNotes` branches below (refresh + * / refresh-failed / not-established / create-failed) each render it, + * and a phrase copied four times is a phrase that drifts in three. + */ + const victimPhrase = (a) => { + const n = a.prs.size; + const c = a.groups.length; + const list = [...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、'); + const tail = a.unresolved > 0 + ? `(其中 ${a.unresolved} 个的队列构建不在 24h 窗口索引内,无法判定栈关系,已按独立计)` + : ''; + if (c === n) return `24h 内已弹出 **${n} 个互相独立的 PR**(${list})${tail}`; + if (c === 1) return `24h 内已弹出 **${n} 个 PR**(${list}),但它们同属 **1 条投机栈** ⇒ **1 次独立命中**${tail}`; + return `24h 内已弹出 **${n} 个 PR**(${list}),分属 **${c} 条投机栈** ⇒ **${c} 次独立命中**${tail}`; + }; + const ANCHOR_LABEL = 'finding'; const MAX_ANCHOR_PAGES = 3; const anchorNotes = []; @@ -387,19 +517,47 @@ jobs: // Stable across refreshes — the victim count lives in the body, so // a growing count cannot make the anchor unfindable by title. const title = `Queue-flake anchor: ${a.key}`; - const prRows = [...a.prs.entries()].sort((x, y) => x[0] - y[0]).map(([pr, runs]) => - `| #${pr} | ${[...runs].map((r) => `[${r}](https://github.com/${owner}/${repo}/actions/runs/${r})`).join(' · ')} |`); + const prRows = [...a.prs.entries()].sort((x, y) => x[0] - y[0]).map(([pr, runs]) => { + const s = a.stackOf.get(pr); + const cell = !s || s.size < 2 + ? 'independent' + : `S${s.index} · ${a.inherited.has(pr) ? 'inherited' : 'root'}`; + return `| #${pr} | ${cell} | ${[...runs].map((r) => `[${r}](https://github.com/${owner}/${repo}/actions/runs/${r})`).join(' · ')} |`; + }); + const independent = a.groups.length; const body = [ - `\`${a.key}\` has ejected **${a.prs.size} distinct pull requests** from the merge queue`, - 'within a rolling 24 hours. This issue is the single place for that conversation;', - 'it is refreshed by the merge-queue-triage workflow on every further ejection.', + `\`${a.key}\` has ejected **${a.prs.size} pull request${a.prs.size === 1 ? '' : 's'}** from the merge`, + `queue within a rolling 24 hours — **${independent} independent hit${independent === 1 ? '' : 's'}** once`, + "GitHub's speculative stacking is accounted for. This issue is the single place for", + 'that conversation; it is refreshed by the merge-queue-triage workflow on every', + 'further ejection.', '', - '| PR | queue build |', - '|---|---|', + '| PR | stack | queue build |', + '|---|---|---|', ...prRows, '', + ...(independent < a.prs.size + ? [ + "⚠️ **Queue depth is not evidence.** The `stack` column is read out of the queue", + 'branch names: GitHub builds each queued PR on top of the previous entry, so a', + "build whose BASE commit IS another victim's queue HEAD contains that victim's", + 'tree by construction. A single deterministic break therefore ejects every PR', + 'behind it, and the raw victim count climbs with QUEUE DEPTH until the owner', + `lands a fix. Start with the ${independent === 1 ? 'root' : 'roots'} above; an ` + + '`inherited` row is a bystander until shown otherwise.', + '', + ] + : []), + ...(a.unresolved > 0 + ? [ + `⚠️ ${a.unresolved} of the rows above could not be placed in a stack at all (their`, + 'queue build fell outside the 24 h run index). They are counted as INDEPENDENT', + 'here, which is the safe direction but may overstate the independent-hit count.', + '', + ] + : []), '**This issue is a NAME, not a diagnosis.** The workflow that files it reads the', - 'failing test file path out of the job logs and counts distinct PRs; it does not', + 'failing test file path out of the job logs and counts PRs; it does not', 'know whether this is a flake, a load/timing cliff, a semantic conflict between', 'queued PRs, or a real regression, and it does not act on any of those. No test is', 'skipped, quarantined or re-queued by it, and no PR is labelled by it — weakening', @@ -446,9 +604,9 @@ jobs: await github.rest.issues.update({ owner, repo, issue_number: existing.number, body, }); - anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。汇总 issue:#${existing.number}(已刷新)`); + anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。汇总 issue:#${existing.number}(已刷新)`); } catch (error) { - anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。⚠️ 汇总 issue #${existing.number} 刷新失败(${describe(error)}),上面的名单就是全部事实。`); + anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。⚠️ 汇总 issue #${existing.number} 刷新失败(${describe(error)}),上面的名单就是全部事实。`); core.warning(`Could not refresh the anchor issue #${existing.number} for ${a.key} (${describe(error)}).`, { title: 'Queue-signature anchor not refreshed' }); } @@ -460,7 +618,7 @@ jobs: // second anchor for a key that already has one, which is exactly // the duplication the marker exists to prevent — so this says so // instead of guessing. - anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。⚠️ 没能确认是否已有汇总 issue(${refusal ?? `open issue 列表超过 ${MAX_ANCHOR_PAGES} 页仍未匹配`}),本次不新建,避免开出重复的锚点。`); + anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。⚠️ 没能确认是否已有汇总 issue(${refusal ?? `open issue 列表超过 ${MAX_ANCHOR_PAGES} 页仍未匹配`}),本次不新建,避免开出重复的锚点。`); core.warning(`Could not establish whether an anchor issue already exists for ${a.key}; not creating one.`, { title: 'Queue-signature anchor not created' }); continue; @@ -470,9 +628,9 @@ jobs: const created = await github.rest.issues.create({ owner, repo, title, body, labels: [ANCHOR_LABEL], }); - anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。汇总 issue:#${created.data.number}(新建)`); + anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。汇总 issue:#${created.data.number}(新建)`); } catch (error) { - anchorNotes.push(`- \`${a.key}\` — 24h 内已弹出 **${a.prs.size} 个不同 PR**(${[...a.prs.keys()].sort((x, y) => x - y).map((p) => `#${p}`).join('、')})。⚠️ 汇总 issue 新建失败(${describe(error)}),上面的名单就是全部事实。`); + anchorNotes.push(`- \`${a.key}\` — ${victimPhrase(a)}。⚠️ 汇总 issue 新建失败(${describe(error)}),上面的名单就是全部事实。`); core.warning(`Could not file the anchor issue for ${a.key} (${describe(error)}).`, { title: 'Queue-signature anchor not created' }); } diff --git a/scripts/check-merge-queue-triage-outcome.mjs b/scripts/check-merge-queue-triage-outcome.mjs index 05cf906e7c..357a4125fb 100644 --- a/scripts/check-merge-queue-triage-outcome.mjs +++ b/scripts/check-merge-queue-triage-outcome.mjs @@ -235,6 +235,11 @@ function makeDoubles(world) { prNumber: 10008, workflowId: WORKFLOW_ID, headBranch: `gh-readonly-queue/main/pr-10008-${'a'.repeat(40)}`, + // The queue HEAD this build produced. The branch name's suffix is the queue + // BASE, and the PAIR is what limb ② compares to tell an independent hit from + // a speculative-stack inheritance — so a world carrying only one of the two + // could exercise nothing but the "cannot tell" path. + headSha: 'f'.repeat(40), jobs: [], logs: {}, logErrors: {}, @@ -375,6 +380,7 @@ function makeDoubles(world) { id: w.runId, workflow_id: w.workflowId, head_branch: w.headBranch, + head_sha: w.headSha, html_url: `https://github.com/objectstack-ai/objectstack/actions/runs/${w.runId}`, }, }, @@ -437,6 +443,40 @@ const shardJob = (id, name = 'Test Core (3/3)') => ({ const sightingComment = (key, pr, runId) => `### merge queue build failed\n\n`; +/** + * A merge_group run as `listWorkflowRunsForRepo` returns it. + * + * `base` is the sha the queue branch NAME carries (the commit this build was + * stacked on); `head` is the commit the build actually produced. Limb ②'s stack + * test is `one victim's base === another victim's head`, so a fixture that let + * these two default to the same shape could not tell the test from a tautology. + */ +const queueRun = (id, pr, base, head) => ({ + id, + workflow_id: WORKFLOW_ID, + conclusion: 'failure', + head_branch: `gh-readonly-queue/main/pr-${pr}-${base}`, + head_sha: head, +}); + +/** + * The 2026-08-27 incident's three queue builds, as the card that filed this + * work recorded them. + * + * The shas are the incident's OWN abbreviations right-padded to 40 hex — the + * source table abbreviates, so the full commits are not recoverable from it and + * inventing look-alike full shas would misrepresent the record. What is + * reproduced faithfully is the SHAPE that matters: `pr-12851`'s base IS + * `pr-12843`'s head, and `pr-12855`'s base IS `pr-12851`'s head. + */ +const pad = (short) => short.padEnd(40, '0'); +const INCIDENT = { + mainTip: pad('b489d3c72'), // main before the queue + head12843: pad('e875b2f18'), // #12843's queue head — and #12851's base + head12851: pad('e391b5fb14'),// #12851's queue head — and #12855's base + head12855: pad('1a24e9778'), +}; + /** The anchor issue an earlier pair already produced. */ const anchorIssue = (number, key) => ({ number, @@ -679,6 +719,81 @@ function scenarios(root) { ]; }, }, + { + id: 'A12', + name: 'SPECULATIVE STACK: three victims of ONE stack score as ONE independent hit', + // The recorded incident, re-scored. Its three "distinct pull requests" + // were #12843 and the two PRs GitHub had queued on top of it; only #12843 + // owned the failure, and establishing that by hand cost two seats real + // time. A change to a counting rule that cannot re-score the incident that + // motivated it is not verified, so this scenario IS that incident. + world: () => base({ + runId: 33141213420, + prNumber: 12855, + headSha: INCIDENT.head12855, + headBranch: `gh-readonly-queue/main/pr-12855-${INCIDENT.head12851}`, + repoComments: [ + sightingComment(KEY_A, 12843, 33140380738), + sightingComment(KEY_A, 12851, 33141029941), + ], + queueRuns: [ + queueRun(33140380738, 12843, INCIDENT.mainTip, INCIDENT.head12843), + queueRun(33141029941, 12851, INCIDENT.head12843, INCIDENT.head12851), + ], + }), + check(r, t) { + const body = r.calls.issueCreate[0]?.body ?? ''; + return [ + t(r.calls.issueCreate.length === 1, + `the anchor is STILL filed — the trigger is untouched, only the count is honest (got ${r.calls.issueCreate.length})`), + t(/\*\*3 pull requests\*\*/.test(body), + `the raw victim count is still reported as 3 rather than hidden, got: ${JSON.stringify(body.slice(0, 240))}`), + t(/\*\*1 independent hit\*\*/.test(body), + `and it is SCORED as one independent hit, got: ${JSON.stringify(body.slice(0, 240))}`), + t(/\|\s*#12843\s*\|\s*S1 · root\s*\|/.test(body), + `#12843 — the only PR that owned the failure — is the stack ROOT, got: ${JSON.stringify(body)}`), + t(/\|\s*#12851\s*\|\s*S1 · inherited\s*\|/.test(body) + && /\|\s*#12855\s*\|\s*S1 · inherited\s*\|/.test(body), + '#12851 and #12855 are marked INHERITED — they were merely behind it in the queue'), + t(body.includes('Queue depth is not evidence'), + 'the anchor says WHY the raw count overstates, instead of just printing a smaller number'), + t(postedBody(r).includes('同属 **1 条投机栈**'), + "the victim's own triage comment carries the same reading as the anchor"), + ]; + }, + }, + { + id: 'A13', + name: 'FALSE-NEGATIVE GUARD: three UNRELATED victims still count as three', + // The symmetric risk, and the reason the stack test is `base === head` on + // FULL shas: a prefix comparison or a time ordering would fuse these three + // into one stack and under-report a real cross-PR signal. Trading the false + // positive for a false negative is strictly worse here. + world: () => base({ + repoComments: [ + sightingComment(KEY_A, 10105, 32328768059), + sightingComment(KEY_A, 10003, 32328768060), + ], + queueRuns: [ + queueRun(32328768059, 10105, 'b'.repeat(40), 'c'.repeat(40)), + queueRun(32328768060, 10003, 'd'.repeat(40), 'e'.repeat(40)), + ], + }), + check(r, t) { + const body = r.calls.issueCreate[0]?.body ?? ''; + return [ + t(r.calls.issueCreate.length === 1, `one anchor is filed, got ${r.calls.issueCreate.length}`), + t(/\*\*3 pull requests\*\*/.test(body) && /\*\*3 independent hits\*\*/.test(body), + `three victims, three independent hits — no stack was invented, got: ${JSON.stringify(body.slice(0, 240))}`), + t((body.match(/\|\s*independent\s*\|/g) ?? []).length === 3, + `every row is marked independent, got: ${JSON.stringify(body)}`), + t(!body.includes('Queue depth is not evidence'), + 'the stack-inheritance caveat is ABSENT — it must not become boilerplate on every anchor'), + t(!/could not be placed in a stack/.test(body), + 'every victim WAS placed, so the "unresolved" hedge does not appear either'), + ]; + }, + }, { id: 'A3', name: 'NEGATIVE: two ejections with DIFFERENT keys produce no anchor at all', @@ -949,6 +1064,14 @@ function list() { // is a FAILURE here, not a skip: the substitution would then be a no-op, the // battery would stay green, and the self-test would report a detector it never // exercised. +// +// `expect` names the scenarios that must go RED. The optional `keepGreen` names +// scenarios that must NOT move — the other half of the reading, and the only +// half that can catch a detector which reds on everything. A rule that decides +// "these two victims are the same hit" has a false NEGATIVE direction as well as +// a false positive one, and a mutation test that only ever asks for more red +// cannot see it: a stack test degraded into "always one stack" would turn every +// count into 1 and still satisfy every `expect` in this table. const MUTATIONS = [ { @@ -1049,6 +1172,40 @@ const MUTATIONS = [ to: ' core.setFailed(`Could not file the anchor issue for ${a.key} (${describe(error)}).`,', expect: ['A9'], }, + { + id: 'M15', + what: 'the queue base sha goes back to being MATCHED but not CAPTURED, so speculative-stack inheritance is invisible again and queue depth reads as victim count', + from: 'const QUEUE_REF = /^gh-readonly-queue\\/.+\\/pr-(\\d+)-([0-9a-f]{40})$/;', + to: 'const QUEUE_REF = /^gh-readonly-queue\\/.+\\/pr-(\\d+)-[0-9a-f]{40}$/;', + expect: ['A12'], + // ⚠️ A13 must NOT move. Three unrelated victims read as three either way, so + // it is the control that proves A12's red comes from the stack test rather + // than from the anchor body simply having changed shape. + keepGreen: ['A13'], + }, + { + id: 'M16', + what: 'same-stack is inferred from CO-OCCURRENCE instead of base === head, so unrelated victims fuse into one stack and a real cross-PR signal is under-reported', + from: ' for (const upstream of producers.get(e.base) ?? []) {', + to: ' for (const upstream of [...producers.values()].flatMap((s) => [...s])) {', + // The mirror of M15, and the reason both exist. M15 removes the evidence and + // the count goes UP; M16 stops requiring it and the count goes DOWN. A stack + // rule can be wrong in either direction, and a battery that only catches one + // of them would sign off on "everything is one stack" — which reports 1 + // independent hit for every anchor it ever files. + // + // ⚠️ A12 is listed here as MEASURED, not as predicted: this mutation leaves + // A12's count at "1 independent hit" (its three victims really are one + // stack) and it was first written with A12 as the control. A12 reds anyway, + // because co-occurrence marks #12843 `inherited` too — it unions with the + // PRs BEHIND it — and a stack whose every row is inherited names no owner to + // start from. That the root marker is the sharper detector here, not the + // count, is the useful half of this entry. + expect: ['A13', 'A12'], + // A1 carries no stack of its own, so it is the control that this mutation + // does not simply red the battery. + keepGreen: ['A1'], + }, { id: 'M13', what: 'the redelivery guard stops returning, so a repeated delivery re-posts and double-counts its own signature', @@ -1089,6 +1246,15 @@ async function selfTest() { assert(red.failures.some((f) => f.id === id), `${m.id}: scenario ${id} is one of the ones that catches it, got [${[...new Set(red.failures.map((f) => f.id))].join(', ')}]`); } + // The dual. A scenario named here must survive the mutation untouched -- + // that is what makes its GREEN a reading rather than an absence, and it is + // the only assertion that can fail a detector which simply reds on + // everything. + for (const id of m.keepGreen ?? []) { + assert(!red.failures.some((f) => f.id === id), + `${m.id}: scenario ${id} must NOT move -- it is the control for this mutation, ` + + `got failures [${red.failures.filter((f) => f.id === id).map((f) => f.message).join(' | ')}]`); + } } // 3. A script that does not compile is caught before any scenario runs.