diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 6a2dc979c3..081df968d6 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -702,11 +702,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the #11705 generator-owned rows inside `skills/**`': 23, '#11705 end to end, against the REAL generator': 7, "the live battery's prerequisite, and the floor it was misread as": 17, + '⭐ #15406: the sweep row names the register it does not recompute': 10, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 23; +const SELF_TEST_BATTERY_FLOOR = 24; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1172,7 +1173,18 @@ export function renderExceptionLines(verdict) { /** The words a seat reads before flipping ready. Pure, so --self-test pins them. */ export function renderTestVerdict(verdict) { - const head = `governed-surface predicate: ${verdict.hitPaths.length} of ${verdict.checked} path(s) hit the register (${verdict.surfacesChecked} surfaces, repo-agnostic).`; + // `hitPaths` is the POST-lift set, so on a diff whose only register hit was a + // certified regeneration this counted 0 — printed directly above the exception + // line naming that very path as a register hit (#15406, measured on PR #15284: + // "0 of 11 path(s) hit the register" over one lifted hit). The count keeps its + // meaning — what is STILL governed — and now says when the register lifted the + // difference. ⛔ Byte-identical when nothing was lifted: the clause appears only + // when there is a lift to name. + const liftedCount = (verdict.exceptions ?? []).filter((e) => e.pureRegeneration).length; + const head = + `governed-surface predicate: ${verdict.hitPaths.length} of ${verdict.checked} path(s) hit the register` + + (liftedCount > 0 ? ` after ${liftedCount} generated-artifact lift(s)` : '') + + ` (${verdict.surfacesChecked} surfaces, repo-agnostic).`; if (!verdict.governed) { return ( `${head}\n` + @@ -2114,6 +2126,49 @@ export function attributionCell(entry) { return `merged_by NOT LOOKED UP — no attribution reading was recorded for this entry (not a channel failure)`; } +/** + * The register reading a row carries, or `''` — report-only, and pure so + * `--self-test` pins the words. + * + * ⚠️ WHY A ROW SAYS THIS AT ALL (#15406). This sweep classifies with + * `governedPathsIn` and nothing else: it never consults the exception register, + * because provenance is a recompute against the tree a commit landed on and + * this sweep holds no such tree. That conservatism is right — the row is listed + * either way — but it left the row silent about a fact its reader needs. + * Measured on PR #15284: its one `skills/**` path was the register's + * `spec-react-blocks` row, the queue leg recomputed it on that tree, certified + * it byte-exact and LIFTED it, and the landing cleared exactly as the + * 2026-09-01 ruling provides for — while this report rendered a row identical + * in every visible way to a hand-authored governed merge. + * + * ⛔ This cell LIFTS NOTHING and excuses nothing, and it is not a second + * membership mechanism: it calls the register's own `generatedExceptionFor`, + * and it repeats that function's own doctrine rather than softening it — a + * candidate earns the QUESTION, never the answer. Certification, if any, is in + * that landing's own queue-guard log, and this cell says so and says where. + */ +export function registerCell(entry) { + const governed = (entry?.surfaces ?? []).flatMap((s) => s.files); + const rows = governed.map((path) => ({ path, row: generatedExceptionFor(path) })).filter((x) => x.row !== null); + if (rows.length === 0) return ''; + const named = rows.slice(0, 4).map((x) => `${x.path} → ${x.row.id} (${x.row.ruling})`); + const all = rows.length === governed.length; + return ( + `\n ℹ️ REGISTER: ${all ? 'every' : `${rows.length} of ${governed.length}`} governed path(s) on this row ` + + `${all ? 'is' : 'are'} a generated-artifact CANDIDATE:\n` + + named.map((n) => ` ${n}`).join('\n') + + (rows.length > named.length ? `\n … and ${rows.length - named.length} more` : '') + + '\n A candidate earns the QUESTION, never the answer: this sweep does NOT recompute provenance' + + '\n (it holds no tree to recompute against), so whether the queue leg certified it byte-exact and' + + '\n LIFTED it is recorded in the Governed Surface Queue Guard log of that landing. A hand edit to' + + '\n the same path is never lifted.' + + (all + ? '' + : '\n The other governed path(s) on this row are on no register row at all, so this row is governed' + + '\n regardless of any recompute.') + ); +} + /** * The window, in the words the operator reads — pure, and the half of route A * the #12633 ruling names explicitly: the back-off has to be SAID, or a @@ -2205,7 +2260,7 @@ export function renderReport({ window, repos, scanned, entries, lookups, sweepCo const who = attributionCell(e); const prName = e.pr != null ? `PR #${e.pr}` : '⚠️ NO PR NUMBER IN SUBJECT — direct push to main? investigate'; const files = e.surfaces.flatMap((s) => s.files.slice(0, 6)).slice(0, 8); - return ` • ${e.repoSlug ? `${e.repoSlug} ` : ''}${prName} — ${e.subject}\n commit ${e.sha.slice(0, 9)} @ ${e.date}; ${who}\n surfaces: ${surfaces}\n${files.map((f) => ` - ${f}`).join('\n')}`; + return ` • ${e.repoSlug ? `${e.repoSlug} ` : ''}${prName} — ${e.subject}\n commit ${e.sha.slice(0, 9)} @ ${e.date}; ${who}\n surfaces: ${surfaces}\n${files.map((f) => ` - ${f}`).join('\n')}${registerCell(e)}`; }); const notes = summariseAttributionFailures(entries).map((l) => ` ${l}`); @@ -3856,6 +3911,116 @@ async function selfTest() { } } + // ── ⭐ #15406: the sweep row names the register it does not recompute ───── + // + // Replays objectstack PR #15284, the landing filed as "the mixed-diff + // diversion did not fire". The diversion had fired and found nothing to fork: + // the diff's one governed path was this register's `spec-react-blocks` row, + // the queue leg recomputed it on that tree, certified it byte-exact and + // LIFTED it. Two report-side readings made a compliant landing read as a + // mechanism failure, and both are pinned here: + // + // 1. the `--test` head counted the POST-lift set, printing "0 of 11 path(s) + // hit the register" immediately above the exception line naming the hit; + // 2. this sweep classifies with `governedPathsIn` alone — deliberately, it + // holds no tree to recompute against — so the row it renders for a + // certified regeneration is indistinguishable from one for a + // hand-authored governed merge. + // + // ⛔ Neither the predicate nor what gets LISTED changes: (2) is still listed, + // still governed, still the director's to read. Only the words change. + battery('⭐ #15406: the sweep row names the register it does not recompute'); + const pr15284Files = [ + '.changeset/list-view-grouping-server-side-contract.md', + 'content/docs/references/ui/view.mdx', + 'packages/spec/src/ui/view.zod.ts', + 'skills/objectstack-ui/references/react-blocks.md', + ]; + const pr15284ReactBlocks = 'skills/objectstack-ui/references/react-blocks.md'; + // The audit leg, unchanged and asserted to be unchanged: this sweep still + // classifies that commit as a governed merge. The register is NOT consulted + // here, and this case is what stops a later reader from "fixing" that. + const pr15284Entry = classifyCommit( + { sha: 'f502898a49530a1c85e58f3c4d2b340c0e1cb909', date: '2026-09-04T13:08:11Z', subject: 'feat(spec): list-view grouping is server-side (#15284)' }, + pr15284Files, + GOVERNED_REPOS[0], + ); + assert( + '⭐ the-sweep-still-CLASSIFIES-a-certified-regeneration-as-a-governed-merge', + pr15284Entry !== null && pr15284Entry.pr === 15284 && pr15284Entry.surfaces.flatMap((s) => s.files).join() === pr15284ReactBlocks, + JSON.stringify(pr15284Entry), + ); + const cell15284 = registerCell(pr15284Entry); + assert('the-row-names-the-register-row-and-its-ruling', cell15284.includes(`${pr15284ReactBlocks} → spec-react-blocks (#11705)`), cell15284); + assert('the-row-says-EVERY-governed-path-on-it-is-a-candidate', /REGISTER: every governed path\(s\) on this row is a generated-artifact CANDIDATE/.test(cell15284), cell15284); + assert( + '⭐ the-row-states-that-this-sweep-does-NOT-recompute-and-says-where-certification-is-recorded', + /does NOT recompute provenance/.test(cell15284) && /Governed Surface Queue Guard log/.test(cell15284), + cell15284, + ); + assert( + '⭐ the-row-repeats-the-registers-doctrine-rather-than-softening-it-a-candidate-is-a-QUESTION', + /A candidate earns the QUESTION, never the answer/.test(cell15284) && /A hand edit to\n the same path is never lifted\./.test(cell15284), + cell15284, + ); + // A row with no registered path renders EXACTLY as before — the cell is empty + // string, so every ordinary governed merge keeps its pre-#15406 rendering. + const handAuthoredEntry = classifyCommit( + { sha: 'e'.repeat(40), date: '2026-09-04T00:00:00Z', subject: 'docs: rewrite a skill (#15285)' }, + ['skills/objectstack-ui/SKILL.md'], + GOVERNED_REPOS[0], + ); + assert('a-row-with-no-registered-path-renders-byte-identically-the-cell-is-empty', registerCell(handAuthoredEntry) === '', JSON.stringify(registerCell(handAuthoredEntry))); + // Mixed: one registered path beside a hand-authored one. The row is governed + // regardless of any recompute, and must say so rather than reading as partly + // excused. + const mixedEntry = classifyCommit( + { sha: 'b'.repeat(40), date: '2026-09-04T00:00:00Z', subject: 'chore: regenerate and edit (#15286)' }, + [pr15284ReactBlocks, 'skills/objectstack-ui/SKILL.md'], + GOVERNED_REPOS[0], + ); + const mixedCell = registerCell(mixedEntry); + assert( + 'a-mixed-row-counts-the-candidates-and-says-it-is-governed-regardless', + /REGISTER: 1 of 2 governed path\(s\) on this row are a generated-artifact CANDIDATE/.test(mixedCell) && + /governed\n regardless of any recompute/.test(mixedCell), + mixedCell, + ); + // End to end in the report an operator actually reads: the row is still + // listed, still carries its attribution cell, and now carries the register + // reading beneath it. + const swept15284 = renderReport({ + window: dateWindowFor('2026-09-04T00:00:00Z'), + repos: allAudited, + scanned: 40, + entries: [{ ...pr15284Entry, attribution: { mergedBy: 'os-justin', mergedAt: '2026-09-04T13:08:11Z', title: 'x' }, attributionChannel: 'rest' }], + lookups: 1, + }); + assert( + 'the-rendered-sweep-still-LISTS-the-row-and-now-carries-the-register-reading', + swept15284.includes('PR #15284') && swept15284.includes('merged_by os-justin') && swept15284.includes('spec-react-blocks (#11705)'), + swept15284, + ); + assert('and-the-sweep-never-suppresses-such-a-row-it-still-counts-as-a-governed-merge', swept15284.includes('governed-merges sweep: 1 governed merge(s)'), swept15284); + // The `--test` head line, both directions. `hitPaths` is the post-lift set, + // so the count itself is right; what was missing is the reason it moved. + const liftedTest = applyGeneratedExceptions( + testVerdict(pr15284Files), + new Map([[pr15284ReactBlocks, { pureRegeneration: true, reason: 'byte-equal (fixture)' }]]), + ); + const liftedHead = renderTestVerdict(liftedTest).split('\n')[0]; + assert( + '⭐ the-test-head-no-longer-reports-a-post-lift-zero-as-if-nothing-had-hit-the-register', + liftedHead === 'governed-surface predicate: 0 of 4 path(s) hit the register after 1 generated-artifact lift(s) (5 surfaces, repo-agnostic).', + liftedHead, + ); + const plainHead = renderTestVerdict(testVerdict(['packages/spec/src/ui/view.zod.ts'])).split('\n')[0]; + assert( + 'and-a-verdict-with-no-lift-keeps-its-head-line-byte-for-byte', + plainHead === 'governed-surface predicate: 0 of 1 path(s) hit the register (5 surfaces, repo-agnostic).', + plainHead, + ); + // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -3869,7 +4034,7 @@ async function selfTest() { for (const failure of failures) console.error(` • ${failure}`); process.exit(1); } - console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the five-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes — and the live battery's own PREREQUISITE, asked before a single case runs: an uninstalled checkout refuses with the repo-wide NOT-MEASURED code end to end instead of reporting a shrunken battery, while the floor still names the battery, by itself, for a case that genuinely stopped registering).\n ${liveNote}`); + console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the five-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes — and the live battery's own PREREQUISITE, asked before a single case runs: an uninstalled checkout refuses with the repo-wide NOT-MEASURED code end to end instead of reporting a shrunken battery, while the floor still names the battery, by itself, for a case that genuinely stopped registering) — and the #15406 replay of PR #15284: the sweep still CLASSIFIES a certified regeneration as a governed merge and still lists it, its row now names the register row it does not recompute and where certification is recorded, and the --test head no longer reports a post-lift zero as if nothing had hit the register.\n ${liveNote}`); return SELF_TEST_VERDICT; } diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index ae5f041ea8..240297fb1f 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -302,11 +302,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the PR-head reader: throws, and the caller no longer refuses on it': 3, 'the WIRING pin: the workflow still spells this context name': 8, '⭐ #14063: the environment the exemption needs, pinned to the YAML': 7, + '⭐ #15406: a CLEAR reached through a lift is not a clear that saw nothing': 10, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 17; +const SELF_TEST_BATTERY_FLOOR = 18; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -559,12 +560,12 @@ export function unreadableApproval(reason) { * The verdict, as data. Pure — every branch of the decision is here, and the * renderer and the exit code both read it rather than re-deriving it. */ -export function guardVerdict({ event, governed = [], unattributed = [], approvals = new Map(), apiCalls = 0, headNotes = [] }) { +export function guardVerdict({ event, governed = [], unattributed = [], approvals = new Map(), apiCalls = 0, headNotes = [], lifted = [] }) { const entries = governed.map((entry) => ({ ...entry, approval: approvals.get(entry.pr) ?? unreadableApproval('no review reading was recorded for this pull request'), })); - const base = { event, entries, unattributed, apiCalls, headNotes, contextName: CHECK_CONTEXT_NAME }; + const base = { event, entries, unattributed, apiCalls, headNotes, lifted, contextName: CHECK_CONTEXT_NAME }; if (entries.length === 0 && unattributed.length === 0) { return { ...base, conclusion: 'clear', exitCode: EXIT_CLEAR, refusalKind: null }; @@ -611,11 +612,36 @@ export function renderGuardVerdict(verdict) { ); if (verdict.conclusion === 'clear') { + // ⭐ TWO DIFFERENT CLEARS, and #15406 is what conflating them costs. The + // wording below used to be unconditional, so a run in which the register + // LIFTED a governed path printed "the diff touches no governed surface" + // immediately under its own LIFTED line — and "the path test runs first and + // returns", when the path test had matched and the register's recompute had + // run. A landing read back from that log looks like a guard that never saw + // the file, which is exactly the reading the post-merge audit was filed on. + // A verdict may not deny its own evidence: the zero-cost clear keeps its + // wording BYTE-FOR-BYTE (both legs, so the pull_request leg's byte-identity + // constraint is untouched), and a clear reached THROUGH a lift says so. + const lifted = verdict.lifted ?? []; + if (lifted.length === 0) { + lines.push( + ' ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge.', + ` Derived from GOVERNED_SURFACES in scripts/pm/check-governed-merges.mjs (${GOVERNED_SURFACES.length} surfaces),`, + ' never from a restated list. ⛔ ZERO review lookups were made: the path test runs first and returns,', + ' so a GitHub API outage can never block a diff that touches nothing governed.', + ); + return lines.join('\n'); + } lines.push( - ' ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge.', + ' ✅ CLEAR — this diff DID touch a governed surface, and every hit was LIFTED by the generated-artifact', + ' register; nothing hand-authored is left for this guard to judge. ⚠️ This is NOT the "no governed', + ' path in the diff" clear: the path test MATCHED, the register recomputed provenance on this tree,', + ' and the lift line(s) printed above are that recompute\'s record. Lifted here:', + ...lifted.slice(0, 12).map((p) => ` - ${p}`), + ...(lifted.length > 12 ? [` … and ${lifted.length - 12} more`] : []), ` Derived from GOVERNED_SURFACES in scripts/pm/check-governed-merges.mjs (${GOVERNED_SURFACES.length} surfaces),`, - ' never from a restated list. ⛔ ZERO review lookups were made: the path test runs first and returns,', - ' so a GitHub API outage can never block a diff that touches nothing governed.', + ' never from a restated list. ⛔ ZERO review lookups were made: the approval predicate is reached only', + ' by a governed path the register did not lift, and there was none.', ); return lines.join('\n'); } @@ -773,10 +799,10 @@ export function renderGuardVerdict(verdict) { * still governs everything the verdict is derived FROM; it never governed * things the verdict merely mentions. */ -export async function runGuard({ event, rows, fetchReviews, fetchPullHead }) { +export async function runGuard({ event, rows, fetchReviews, fetchPullHead, lifted = [] }) { const { governed, unattributed } = decomposeGovernedWork(rows); if (governed.length === 0 && unattributed.length === 0) { - return guardVerdict({ event, governed, unattributed, apiCalls: 0 }); + return guardVerdict({ event, governed, unattributed, apiCalls: 0, lifted }); } const approvals = new Map(); const headNotes = []; @@ -812,7 +838,7 @@ export async function runGuard({ event, rows, fetchReviews, fetchPullHead }) { approvals.set(entry.pr, unreadableApproval(String(error?.message ?? error).split('\n')[0])); } } - return guardVerdict({ event, governed, unattributed, approvals, apiCalls, headNotes }); + return guardVerdict({ event, governed, unattributed, approvals, apiCalls, headNotes, lifted }); } // ── git (diff decomposition; zero API) ────────────────────────────────────── @@ -914,6 +940,34 @@ export async function liftGeneratedExceptions(root, baseSha, rows, notes, recomp return out; } +/** + * Which registered paths `liftGeneratedExceptions` actually LIFTED, derived + * from the row lists on either side of it. Pure, so the rendering that depends + * on it is pinned offline. + * + * Read from the rows rather than from the notes: the notes are prose for a + * human, and a verdict that parsed them back would be deriving a decision from + * a rendering. Membership is still the register's own `generatedExceptionFor` — + * a path that vanished for any other reason is not reported as a lift. + * + * ⚠️ Deliberately conservative across rows: a path kept by ANY row is not + * listed, because the #11084 fence is per-row and one row's co-edit can keep a + * path governed that another row's recompute certified. Under-reporting a lift + * only ever costs a line of log; over-reporting one would put "we lifted it" + * next to a verdict that did not. + */ +export function liftedPathsBetween(before, after) { + const kept = new Set((Array.isArray(after) ? after : []).flatMap((r) => r?.paths ?? [])); + const out = []; + for (const row of Array.isArray(before) ? before : []) { + for (const p of row?.paths ?? []) { + if (kept.has(p) || generatedExceptionFor(p) === null || out.includes(p)) continue; + out.push(p); + } + } + return out; +} + /** * The workflow's toolchain wiring, as data — the #14063 half of the wiring pin. * @@ -1048,10 +1102,13 @@ async function main() { const notes = []; let rows; + let lifted = []; try { const mergeBase = git(repoRoot, ['merge-base', context.baseSha, context.headSha]).trim(); rows = enumerateRows(repoRoot, mergeBase, context.headSha, context.namedPull); + const beforeLift = rows; rows = await liftGeneratedExceptions(repoRoot, mergeBase, rows, notes); + lifted = liftedPathsBetween(beforeLift, rows); } catch (error) { console.error(`⛔ ${CHECK_CONTEXT_NAME}: could not read the diff (${String(error?.message ?? error).split('\n')[0]}).`); return EXIT_CANNOT_RUN; @@ -1066,7 +1123,7 @@ async function main() { const fetchReviews = makeReviewReader(reader); const fetchPullHead = makePullHeadReader(reader); - const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPullHead }); + const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPullHead, lifted }); const report = [`${context.label} — ${rows.length} commit(s) in range`, ...notes, renderGuardVerdict(verdict)].join('\n'); console.log(report); @@ -1708,6 +1765,141 @@ export async function selfTest() { } assert('a-recompute-that-THROWS-never-lifts-it-propagates-into-CANNOT-RUN', liftThrew !== null && /EACCES/.test(liftThrew), String(liftThrew)); + // ── ⭐ #15406: a CLEAR reached through a lift is not a clear that saw nothing ─ + // + // Replays the real diff shape of objectstack#15284 — the landing that was + // filed as "the mixed-diff diversion did not fire". It had NOT failed: the + // diff's single governed path was the register's own `spec-react-blocks` + // row, the recompute certified it byte-exact, and the queue leg cleared with + // zero approvals exactly as the 2026-09-01 ruling provides for. What failed + // was the LOG. Its verdict line read, directly under its own LIFTED line: + // + // ✅ CLEAR — the diff touches no governed surface, so this guard has + // nothing to judge. + // … ⛔ ZERO review lookups were made: the path test runs first and returns + // + // Both sentences are false for that run: the path test MATCHED, and the + // register's recompute ran. Read back from the merge queue log, that landing + // is indistinguishable from a guard that never saw the file — which is the + // reading the post-merge audit row was filed on. ⛔ The predicate is NOT what + // these cases pin: the verdict, the exit code and the API cost are asserted + // to be the SAME as before, and only the words change. + battery('⭐ #15406: a CLEAR reached through a lift is not a clear that saw nothing'); + // #15284's real file list, in its merged order. + const pr15284Paths = [ + '.changeset/list-view-grouping-server-side-contract.md', + 'content/docs/references/api/protocol.mdx', + 'content/docs/references/data/object.mdx', + 'content/docs/references/ui/view.mdx', + 'packages/spec/api-surface/ui.json', + 'packages/spec/export-origins/ui.json', + 'packages/spec/src/ui/index.ts', + 'packages/spec/src/ui/view-grouping-query.test.ts', + 'packages/spec/src/ui/view-grouping-query.ts', + 'packages/spec/src/ui/view.zod.ts', + 'skills/objectstack-ui/references/react-blocks.md', + ]; + const pr15284ReactBlocks = 'skills/objectstack-ui/references/react-blocks.md'; + const pr15284Row = (paths = pr15284Paths) => ({ + sha: 'f502898a49530a1c85e58f3c4d2b340c0e1cb909', + subject: 'feat(spec): list-view grouping is server-side (#15284)', + pr: 15284, + paths, + }); + // main()'s own wiring, reproduced: lift, derive what was lifted, then judge. + // ⛔ Not the `endToEnd` helper above — the defect lived in the step BETWEEN + // those two, so a helper that skips it cannot see it. + const asMainDoes = async (recompute, rows, io = {}) => { + const notes = []; + const after = await liftGeneratedExceptions('/w', 'base', rows, notes, recompute); + const lifted = liftedPathsBetween(rows, after); + const verdict = await runGuard({ + event: 'merge_group', + rows: after, + fetchPullHead: io.head ?? (() => HEAD), + fetchReviews: io.reviews ?? (() => []), + lifted, + }); + return { verdict, notes, lifted, text: renderGuardVerdict(verdict) }; + }; + const noApi = () => { + throw new Error('the API must not be reached — the only governed path was lifted'); + }; + // The classification leg first: the file IS on the register, and the diff DID + // hit a governed surface before anything was lifted. Both are facts the old + // verdict line denied. + assert( + 'the-one-skills-path-in-15284-is-a-register-CANDIDATE-not-hand-authored-content', + generatedExceptionFor(pr15284ReactBlocks)?.id === 'spec-react-blocks', + JSON.stringify(generatedExceptionFor(pr15284ReactBlocks)), + ); + assert( + '⭐ the-mixed-diff-rule-DID-match-15284-before-the-lift-one-hit-out-of-eleven-paths', + governedPathsIn(pr15284Paths).flatMap((s) => s.files).join() === pr15284ReactBlocks, + JSON.stringify(governedPathsIn(pr15284Paths)), + ); + const certified15284 = await asMainDoes( + verified('byte-equal to the react-blocks generator recomputed on this tree (fixture)'), + [pr15284Row()], + { head: noApi, reviews: noApi }, + ); + // The verdict itself is UNCHANGED — the 2026-09-01 ruling working, with an + // approver set that does not contain the merging account and no review of any + // kind on the pull request. + assert( + 'a-certified-regeneration-still-CLEARS-with-zero-reviews-and-zero-api-calls', + certified15284.verdict.conclusion === 'clear' && certified15284.verdict.exitCode === EXIT_CLEAR && certified15284.verdict.apiCalls === 0, + JSON.stringify({ c: certified15284.verdict.conclusion, e: certified15284.verdict.exitCode, api: certified15284.verdict.apiCalls }), + ); + assert( + 'liftedPathsBetween-names-exactly-the-path-the-register-lifted', + certified15284.lifted.join() === pr15284ReactBlocks, + JSON.stringify(certified15284.lifted), + ); + assert( + '⭐ the-CLEAR-line-does-NOT-claim-the-diff-touched-no-governed-surface', + !certified15284.text.includes('the diff touches no governed surface'), + certified15284.text, + ); + assert( + '⭐ the-CLEAR-line-does-NOT-claim-the-path-test-returned-before-matching', + !certified15284.text.includes('the path test runs first and returns'), + certified15284.text, + ); + assert( + 'the-CLEAR-line-names-the-lifted-path-and-says-the-recompute-ran', + certified15284.text.includes(pr15284ReactBlocks) && + /LIFTED/.test(certified15284.text) && + /recomputed provenance on this tree/.test(certified15284.text), + certified15284.text, + ); + assert( + 'and-it-still-reports-the-zero-lookup-cost-a-reader-checks-for', + /ZERO review lookups/.test(certified15284.text), + certified15284.text, + ); + // The other direction, on the SAME path: a hand edit to that file is not + // lifted, so the identical file list refuses. The shape is not an exemption + // for `references/react-blocks.md`; it is an exemption for a recompute. + const handEdited15284 = await asMainDoes(refused('the generator does not certify this tree (fixture)'), [pr15284Row()]); + assert( + 'a-hand-edit-to-the-SAME-path-still-REFUSES-the-same-file-list', + handEdited15284.verdict.exitCode === EXIT_REFUSED_UNAPPROVED && handEdited15284.lifted.length === 0, + JSON.stringify({ e: handEdited15284.verdict.exitCode, lifted: handEdited15284.lifted }), + ); + // And the zero-cost clear is untouched, to the byte, on BOTH legs — the + // 2026-08-27 byte-identity constraint on the pull_request leg included. + for (const event of [EVENT_MERGE_GROUP, EVENT_PULL_REQUEST]) { + const nothing = renderGuardVerdict(guardVerdict({ event, governed: [], unattributed: [], apiCalls: 0 })); + assert( + `a-clear-with-nothing-lifted-keeps-its-pre-15406-wording-byte-for-byte: ${event}`, + nothing.includes(' ✅ CLEAR — the diff touches no governed surface, so this guard has nothing to judge.') && + nothing.includes(' never from a restated list. ⛔ ZERO review lookups were made: the path test runs first and returns,') && + nothing.includes(' so a GitHub API outage can never block a diff that touches nothing governed.'), + nothing, + ); + } + // ── the PR-head reader: throws, and the caller no longer refuses on it ─── // // The READER's own contract is unchanged — a non-2xx or an unparseable body @@ -1855,7 +2047,8 @@ export async function selfTest() { 'regeneration (clears with zero approvals and zero API calls; still refuses on an uncertified recompute, on ' + 'drift, on a hand-authored sibling, and on a recompute that throws), and the workflow wiring pin including the ' + 'dependency install the recompute needs, its register-agnostic filter-free form, and its continue-on-error ' + - 'degradation).', + 'degradation), and the #15406 replay of PR #15284 — a clear reached THROUGH a lift no longer reports itself as a ' + + 'clear that matched nothing, while the zero-cost clear keeps its wording byte-for-byte on both legs.', ); selfTestReachedVerdict = true;