diff --git a/.claude/skills/pm-dispatch/references/contract-review.md b/.claude/skills/pm-dispatch/references/contract-review.md index 4abd75613a4..629350ee035 100644 --- a/.claude/skills/pm-dispatch/references/contract-review.md +++ b/.claude/skills/pm-dispatch/references/contract-review.md @@ -18,6 +18,7 @@ - FAIL 同 PASS 剥双载体:同笔留卡上交接评论(引复审、独立性对、欠改);卡态与 assignee 不动。 - 重挂前先查裁决:闸门标签缺失 ⇒ 先 grep 卡评论找复审结论;`get_reviews` 读空 ≠ 未复审。 - PASS + 无标 + head 未动 = 已清标不是被剥;head 后移或无结论才重挂;清标缺引记录即半态。 +- 例外:纯重生成 head 后移原记录继续管;判据机读已提交树;PR 落 `Regen-provenance:` 行。 ## 复核归属与资格(按面) @@ -40,7 +41,6 @@ - 轮次报告设复审清单专节,形状与代裁清单同为强制审计。 - 落地前检三条,过则 Tier S 入队、Tier H 等人批:① 达档条款②复核 PASS 在案(同形记录)。 - ② 双载体已清,逐对机读 `PM_SWEEP_REPO=仓 node scripts/pm/check-clause2-carriers.mjs --pair N`。 -- 0 = 确定性行全清;4 = 任一不成立,只确定性行红才挡落地;3 = 环境答不了 ⛔ 不作干净。 - 确定性行 = 记录在案、`Served-tier:`、双载体一致、认领形;C5 放宽 tell 只报告,归复核裁。 - ③ PR check 全绿,⛔ 非 required 子集;例外:merge-base 同签名的红不计、按设计而红见 SKILL.md。 - 签名 = 失败步 + 首错行,读 base check runs 的 API ⛔ 不凭口述;主干红止血立单不变。 diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index 0a8d767289b..9203b8a3c25 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -702,7 +702,7 @@ */ import process from 'node:process'; -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; @@ -863,6 +863,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ '#18862: cross-author LIVE claims with no `Release:` between — the hand-over the protocol never wrote, named; judged only after its effective instant': 52, '#16770: the exit-0 line says which carriers agreed — LABEL carriers — and that the PR body was not read': 14, '#18892: the claim comment\'s EDIT reading — taken from the two stamps already in hand, reported and never failed': 10, + '⭐ the 2026-09-20 ruling: a pure-regeneration head move KEEPS the record, decided on the COMMITTED trees': 25, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -876,8 +877,9 @@ const SELF_TEST_BATTERIES = Object.freeze({ // by the one #16833 adds, and by the one #18456 adds, and by the one #18719 // adds, and by the one #18683 adds, and by the one #18764 adds, and by the one // #18828 adds, and by the one #18536 adds, and by the one #18862 adds, and by -// the one #16770 adds, and by the one #18892 adds. -const SELF_TEST_BATTERY_FLOOR = 36; +// the one #16770 adds, by the one #18892 adds, and by the one the 2026-09-20 +// pure-regeneration ruling adds. +const SELF_TEST_BATTERY_FLOOR = 37; // 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 @@ -3116,6 +3118,180 @@ export function needsGateHistory(pair) { return !onCard && !onPr; } +// ── the pure-regeneration carry (maintainer 2026-09-20 「纯重生成提交不需要开达档复核记录」) ── +// +// A record binds to a head, so any push re-owes the review — a loop the reviewed +// seat cannot exit on a generated-artefact-dense surface: somebody else lands, +// baselines drift, the seat regenerates, the head moves, the record is owed +// again (four times in one round, two PASSed pull requests unlanded). The ruling +// narrows it: when the move is a PURE REGENERATION the record keeps pointing at +// the new head — the POINTER test replaced by the CONTENT test it stood for. +// +// ⛔ MACHINE-READ ON COMMITTED TREES, never a seat's statement, and the committed +// half is not stylistic: before `git add -A` one regeneration answers `git +// status`, `git diff --cached` and `git diff` three DIFFERENT ways, and the +// `--cached` reading is main's side, which looks exactly like the answer. ⭐ The +// `Regen-provenance:` line is a POINTER, NEVER the evidence: it names the record +// and the two commits so a later reader RE-RUNS the test, and a reader that +// cannot reach both answers with a GAP — UNJUDGED, ⛔ never clean. + +/** + * One hop, as a seat posts it (the printable shape is in C3's own remedy): + * `Regen-provenance: RECORD-ID · OLD-HEAD → NEW-HEAD · COMMAND → (empty)`. + * Decoration is tolerated as `REVIEWED_BY_LINE` tolerates it — bullet, bold, + * backticked shas — since none of it changes which commits the line names; the + * tail after the second sha is the seat's own transcript and is deliberately + * UNREAD, because a reader re-runs its own command, never a pasted one. + */ +export const REGEN_PROVENANCE_LINE = + /^[\s>]*(?:[-*+]\s*)?\**\s*Regen-provenance\**\s*:\s*`?#?(\d+)`?\s*[·•]\s*`?([0-9a-fA-F]{7,40})`?\s*(?:→|->)\s*`?([0-9a-fA-F]{7,40})`?/; + +/** Every hop the pair's threads carry, in thread order. ⛔ No head is judged here. */ +export function regenProvenanceHops(pair) { + return REVIEW_OF_RECORD_THREADS.flatMap((t) => (Array.isArray(pair?.[t.rows]) ? pair[t.rows] : [])) + .flatMap((row) => String(row?.body ?? '').split(/\r?\n/)) + .map((line) => REGEN_PROVENANCE_LINE.exec(line)) + .filter((m) => m !== null) + .map((m) => ({ record: Number(m[1]), from: m[2].toLowerCase(), to: m[3].toLowerCase() })); +} + +/** Either sha abbreviates the other — a seat writes 7, the API writes 40. */ +const shaMeets = (a, b) => a.startsWith(b) || b.startsWith(a); + +/** + * The hops that chain BACK from this head, oldest first — `null` when none does. + * Several hops are ordinary: a PR is re-synced once per drift. ⛔ Two hops + * arriving at one head END the walk rather than being ranked, so a contradictory + * thread carries no chain — the only direction this may err in. + */ +export function regenChainToHead(pair) { + // ⭐ DE-DUPLICATED FIRST, on record + from + to. The reader searches BOTH + // carriers and the governed text trains the dual-carrier habit, so ONE hop + // posted on the PR and on its card arrives here twice — identical bytes, not + // ambiguity. Judged by the ambiguity test below it would END the walk and + // kill the carry in its most likely shape; two DIFFERENT hops into one head + // still do, which is the fact that test exists for. + const hops = [ + ...new Map(regenProvenanceHops(pair).map((h) => [`${h.record}\u0000${h.from}\u0000${h.to}`, h])).values(), + ]; + let target = String(pair?.headSha ?? '').toLowerCase(); + if (target.length < H51_SHA_MIN_HEX) return null; + const chain = []; + const seen = new Set(); + while (!seen.has(target)) { + seen.add(target); + const step = hops.filter((h) => shaMeets(h.to, target)); + if (step.length !== 1) break; + chain.unshift(step[0]); + target = step[0].from; + } + return chain.length === 0 ? null : chain; +} + +/** + * The ruled test on COMMITTED trees: which moved paths are explained by NEITHER + * arm of 「every touched path is a generated artefact OR THE MERGE COMMIT'S OWN + * CARRY-OVER FROM MAIN」. Empty is the whole criterion. + * + * ① the `merge=os-regen` attribute, read with `--source ` so + * `.gitattributes` itself comes out of that COMMIT rather than out of whatever + * the working tree holds. ② the carry-over arm: a path this pull request never + * touched AT EITHER HEAD moved only because `base` moved. ⛔ Without ② the + * exception never fires on the loop this card measured — a merge-forward lists + * every path main carried over, and a head-to-head diff reads them as hand-written. + * + * ⭐ The PR's OWN delta is read once per HEAD (`merge-base` + one name-only + * diff), never once per path: a path is byte-explained by `base` exactly when + * neither delta names it, which is the same verdict as comparing + * `git diff FROM TO -- p` with `git diff MB_FROM MB_TO -- p` per path, at four + * calls instead of two per path. The refusing direction is unchanged — a + * hand-resolved merge leaves its resolution in the delta at the NEW head, and a + * slipped-in edit in whichever delta carries it. `-z` throughout: a path may + * hold a space, a quote or a colon and the parse must not be the weak link. + */ +export function unexplainedPathsBetween(runGit, { from, to, base }) { + const names = (a, b) => String(runGit(['diff', '-z', '--name-only', a, b])).split('\0').filter((p) => p !== ''); + const moved = names(from, to); + if (moved.length === 0) return []; + const f = String(runGit(['check-attr', '--source', to, '-z', 'merge', '--stdin'], moved.join('\0'))).split('\0'); + const hand = []; + for (let i = 0; i + 2 < f.length; i += 3) if (f[i + 2] !== 'os-regen') hand.push(f[i]); + if (hand.length === 0) return []; + const mergeBase = (rev) => String(runGit(['merge-base', base, rev])).trim(); + const own = new Set([...names(mergeBase(from), from), ...names(mergeBase(to), to)]); + return hand.filter((p) => own.has(p)); +} + +/** + * The git reader the carry re-runs the test with — this checkout, read-only, and + * injectable so the self-test drives every branch offline. Its failure is a GAP, + * never a verdict: a checkout that never fetched the recorded head, or a git + * without `check-attr --source`, cannot answer this question at all. + */ +export const REPO_GIT = (args, input) => + execFileSync('git', args, { encoding: 'utf8', input, maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }); + +const REGEN_CARRY_MEMO = new WeakMap(); + +/** + * Does a chain of certified pure regenerations carry the record to this head? + * Memoised per pair: `gateBindingState` asks several times per run and two + * commits cannot change under one. ⛔ FOUR states, none foldable: `none` (no + * line — today's rule, unchanged), `refused` (a line that does NOT certify: an + * ordinary re-hang), `unreadable` (the environment could not answer), `carried`. + * + * @returns {{ state: 'none' } | { state: 'unreadable', gaps: string[] } + * | { state: 'refused', reason: string } + * | { state: 'carried', record: number, head: string, hops: number }} + */ +export function regenCarry(pair) { + if (pair === null || typeof pair !== 'object') return { state: 'none' }; + if (!REGEN_CARRY_MEMO.has(pair)) REGEN_CARRY_MEMO.set(pair, computeRegenCarry(pair)); + return REGEN_CARRY_MEMO.get(pair); +} + +function computeRegenCarry(pair) { + const chain = regenChainToHead(pair); + if (chain === null) return { state: 'none' }; + const records = [...new Set(chain.map((h) => h.record))]; + if (records.length !== 1) { + return { state: 'refused', reason: `its \`Regen-provenance:\` hops name ${records.length} different records (${records.join(', ')}) and one chain carries ONE record` }; + } + const runGit = pair?.runGit; + if (typeof runGit !== 'function') { + return { state: 'unreadable', gaps: [`PR #${pair?.pr}'s \`Regen-provenance:\` chain — this run holds no git reader, so the two committed trees were never compared`] }; + } + // ⛔ The base ref is what separates 「what main brought」 from 「what the PR + // changed」, so a run that names none cannot answer at all — never clean. + const base = pair?.baseRef; + if (typeof base !== 'string' || base === '') { + return { state: 'unreadable', gaps: [`PR #${pair?.pr}'s base ref — this run names none, so the merge commit's own carry-over could not be told apart from a hand edit`] }; + } + for (const hop of chain) { + const span = `${hop.from.slice(0, 10)}..${hop.to.slice(0, 10)}`; + let hand; + try { + hand = unexplainedPathsBetween(runGit, { from: hop.from, to: hop.to, base }); + } catch (error) { + const why = String(error?.message ?? error).split('\n')[0]; + return { state: 'unreadable', gaps: [`the committed trees ${span} against base \`${base}\` (${why}) — fetch both (\`git fetch origin pull/${pair?.pr}/head\`, \`git fetch origin ${hop.from}\`) and re-run`] }; + } + if (hand.length > 0) { + return { state: 'refused', reason: `${span} moved ${hand.length} path(s) that carry no \`merge=os-regen\` attribute and are not what \`${base}\` brought (${hand.slice(0, 4).join(', ')}) — this pull request's own hand-written content moved, so it is no pure regeneration` }; + } + } + // Expanded through git so the carried head is spelled at least as fully as + // the record spells it: the head-identity test is a PREFIX of the head, so an + // abbreviation SHORTER than the record's own span matches nothing. + let head = chain[0].from; + try { + head = String(runGit(['rev-parse', `${head}^{commit}`])).trim() || head; + } catch { + /* the diff already read both commits; an abbreviation is still usable */ + } + return { state: 'carried', record: records[0], head, hops: chain.length }; +} + /** * What the two event streams say about the gate this declaration should bind. * @@ -3141,7 +3317,7 @@ export function needsGateHistory(pair) { * | { state: 'still-hung' } * | { state: 'half-bound', bound: 'card'|'pr', at: string } * | { state: 'completed', clearedAt: string } - * | { state: 'moved-after-clear', clearedAt: string, headAt: string }} + * | { state: 'moved-after-clear', clearedAt: string, headAt: string, carry: object }} */ export function gateBindingState(pair) { if (!needsGateHistory(pair)) return { state: 'not-candidate' }; @@ -3173,7 +3349,13 @@ export function gateBindingState(pair) { if (!Number.isFinite(headMs)) { return { state: 'unreadable', gaps: [`PR #${pair?.pr}'s head commit date`] }; } - if (headMs > clearedMs) return { state: 'moved-after-clear', clearedAt, headAt: String(headAt) }; + if (headMs > clearedMs) { + // ⭐ A move certified as a PURE REGENERATION leaves the record governing. + // ⛔ `unreadable` folds into neither answer: a chain nobody re-ran is UNJUDGED. + const carry = regenCarry(pair); + if (carry.state === 'unreadable') return { state: 'unreadable', gaps: carry.gaps }; + if (carry.state !== 'carried') return { state: 'moved-after-clear', clearedAt, headAt: String(headAt), carry }; + } return { state: 'completed', clearedAt }; } @@ -3216,7 +3398,19 @@ export function c3DeclaredYesUngated(pair) { `head has MOVED since: its head commit is dated ${binding.headAt}. The review that cleared ` + 'this gate judged a different tree, so the clear no longer covers what would land. This is ' + 'the 重挂-owed state the recovery rule already names — 「head 后移或无结论才重挂」 — and ' + - `the re-hang is a seat's act, not this script's. ${readsEvents} ${NEVER_WRITES}` + `the re-hang is a seat's act, not this script's.` + + // ⭐ THE FORMAT LIVES HERE, not in the governed text: the rule says a + // provenance line is posted and re-run, and this script owns its detail. + ' ⭐ Unless the move was a PURE REGENERATION, which since 2026-09-20 keeps the record: post ONE line on the ' + + 'PR — `Regen-provenance: RECORD-ID · OLD-HEAD → NEW-HEAD · COMMAND → (empty)`, shas as code spans and angle ' + + 'brackets kept out — and this reader RE-RUNS the test on the COMMITTED trees: of the paths ' + + '`git diff --name-only OLD NEW` lists, none may both carry no `merge=os-regen` attribute and be one this ' + + 'pull request touched at either head. ⛔ The line certifies NOTHING by being present, and a chain this ' + + 'environment cannot re-run is UNJUDGED rather than carried.' + + (binding.carry?.state === 'refused' + ? ` ⚠️ A \`Regen-provenance:\` chain IS on the thread and does NOT certify this move: ${binding.carry.reason}. The 纯重生成 exception is decided on the committed trees, ⛔ never on the line being present.` + : '') + + ` ${readsEvents} ${NEVER_WRITES}` ); case 'half-bound': return ( @@ -4339,7 +4533,7 @@ export function contractReviewTemplateLines(values = {}) { * | { state: 'unsigned', where: 'PR'|'card', id: number|null, sha: string, at: string|null } * | { state: 'found', where: 'PR'|'card', id: number|null, sha: string, at: string|null }} */ -export function locateReviewOfRecord(pair) { +export function locateReviewOfRecord(pair, carriedHead = null) { const gaps = []; // \u2b50 THE THREAD SET IS READ FROM `REVIEW_OF_RECORD_THREADS`, never spelled // here: this loop, the template's printed sentence and the queue guard's @@ -4348,7 +4542,7 @@ export function locateReviewOfRecord(pair) { for (const thread of REVIEW_OF_RECORD_THREADS) { if (!Array.isArray(pair?.[thread.rows])) gaps.push(`${thread.where} #${pair?.[thread.number]}'s comment thread`); } - const head = String(pair?.headSha ?? ''); + const head = String(carriedHead ?? pair?.headSha ?? ''); // A head too short to be matched by H51's span test can never find its // record, so it is a read that could not be made -- never an absent record. if (head.length < H51_SHA_MIN_HEX) gaps.push(`PR #${pair?.pr}'s head sha`); @@ -4364,6 +4558,20 @@ export function locateReviewOfRecord(pair) { ); const newest = latestMarkedComment(onHead.map(({ row }) => row), CONTRACT_REVIEW_HEADING_MARKER); if (!newest) { + // ⭐ The ruled exception, consulted ONLY here — after the ordinary read found + // nothing — so it turns an absence into a record and never the reverse. The + // second read is pinned to the carried head AND to the record id the chain + // names; depth is one, since the recursive call passes that head. + if (carriedHead === null) { + const carry = regenCarry(pair); + if (carry.state === 'unreadable') return { state: 'unreadable', gaps: carry.gaps }; + if (carry.state === 'carried') { + const back = locateReviewOfRecord(pair, carry.head); + if ((back.state === 'found' || back.state === 'unsigned') && back.id === carry.record) { + return { ...back, carriedFrom: carry.head, carriedHops: carry.hops }; + } + } + } return { state: 'absent', read: Object.fromEntries(REVIEW_OF_RECORD_THREADS.map((thread) => [thread.number, pair[thread.rows].length])), @@ -5962,6 +6170,14 @@ async function gather(repo, prFilter = null, reader = NETWORK_READER, { landingR draft: Boolean(pr.draft), card: Number(n), headSha: pr?.head?.sha ?? null, + // The carry's git reader and the base its carry-over arm is measured + // against, read by `regenCarry` alone. They ride the pair rather than a + // parameter so every reader — this file's rows and the queue guard's + // tier leg — reaches the same one mechanism. `origin/main` is the base + // BRANCH here; a merge-base against it is the PR's fork point, which a + // sibling's fetch advancing that ref does not move. + runGit: REPO_GIT, + baseRef: 'origin/main', // ⭐ The pairing's own inputs, carried for the input record (#18456) // and read by nothing else: the evidence kind is the SAME call // `prDeliversCard` just made, so the block states the derivation that @@ -7898,6 +8114,70 @@ export async function selfTest() { t('the offline document serves the PR thread from the same `comments` bag, keyed by the PR number', Array.isArray(pairJsonReader({ pulls: DOC.pulls, comments: { 13910: [] } }).readCardComments('owner/name', 13910))); t('…and one it omits reads null — UNJUDGED, ⛔ never a missing record', pairJsonReader({ pulls: DOC.pulls }).readCardComments('owner/name', 13910) === null); + // -- the 2026-09-20 ruling: a PURE REGENERATION keeps the record ----------- + battery('⭐ the 2026-09-20 ruling: a pure-regeneration head move KEEPS the record, decided on the COMMITTED trees'); + const NEW_HEAD = 'e1ae0257'; // the head a whole-tree regeneration produced, as the board abbreviated it. + const PROV = (from = HEAD_9AF9, to = NEW_HEAD, record = 3301, id = 3350) => ({ id, created_at: '2026-09-01T09:10:00Z', body: `Regen-provenance: ${record} · \`${from}\` → \`${to}\` · \`git diff --name-only\` → (empty)` }); + const GIT_SEEN = []; + const BASE = 'origin/main'; + // A fake git over COMMITTED trees. `moved` is the head-to-head name list, + // `attrs` the `check-attr` answer, `own` the PULL REQUEST'S OWN delta at each + // head (`merge-base BASE head` .. head) — the fact the carry-over arm reads. + const GIT = (spec) => (args) => { + GIT_SEEN.push(args.join(' ')); + if (args[0] === 'merge-base') return `mb-${args[2]}\n`; + if (args[0] === 'check-attr') return spec.attrs; + if (args[0] === 'rev-parse') return `${HEAD_9AF9}00\n`; + const [, , , a, b] = args; // diff -z --name-only A B + return a === HEAD_9AF9 && b === NEW_HEAD ? spec.moved : (spec.own?.[b] ?? ''); + }; + const REGEN_ONLY = { moved: 'packages/spec/api-surface/data.txt\0', attrs: 'packages/spec/api-surface/data.txt\0merge\0os-regen\0' }; + // (i) the loop the card measured: a merge-forward carries a hand-written path + // ANOTHER pull request landed, beside this one's regeneration. + const CARRY_OVER = { moved: 'packages/spec/api-surface/data.txt\0AGENTS.md\0', attrs: 'packages/spec/api-surface/data.txt\0merge\0os-regen\0AGENTS.md\0merge\0unspecified\0', own: {} }; + // (ii) an edit slipped in beside the regeneration: this PR's own delta at the new head names it. + const SEAT_EDIT = { moved: 'packages/spec/api-surface/data.txt\0scripts/pm/x.mjs\0', attrs: 'packages/spec/api-surface/data.txt\0merge\0os-regen\0scripts/pm/x.mjs\0merge\0unspecified\0', own: { [NEW_HEAD]: 'scripts/pm/x.mjs\0' } }; + // (iii) the same path as (i), but the merge was RESOLVED BY HAND, so the new head no longer holds what main brought. + const HAND_RESOLVED = { moved: 'AGENTS.md\0', attrs: 'AGENTS.md\0merge\0unspecified\0', own: { [NEW_HEAD]: 'AGENTS.md\0' } }; + const GIT_EMPTY = GIT(REGEN_ONLY); + const GIT_HAND = GIT(SEAT_EDIT); + const GIT_BLIND = () => { throw new Error(`fatal: bad object ${HEAD_9AF9}`); }; + const moved = (rows, runGit, over = {}) => declaredYes({ pr: 13864, card: 13657, headSha: NEW_HEAD, cardEvents: [CARD_HUNG, CARD_CLEARED], prEvents: [PR_HUNG, PR_CLEARED], headCommittedAt: '2026-09-01T10:30:00Z', prComments: rows, runGit, baseRef: BASE, ...over }); + const CARRIED = () => moved([RECORD_ON_9AF9, PROV()], GIT_EMPTY); + const withGit = (runGit, over) => moved([RECORD_ON_9AF9, PROV()], runGit, over); + // the line and the chain, before any tree is touched + t('the hop is READ off either thread, decorated or bare', regenProvenanceHops({ prComments: [PROV()], cardComments: [{ id: 9, body: '- **Regen-provenance**: `3301` · `aaaaaaa` -> `bbbbbbb`' }] }).length === 2); + t('⛔ a line naming only one sha is not a hop — the tail after it is the seat\'s transcript and is unread', regenProvenanceHops({ prComments: [{ id: 9, body: `Regen-provenance: 3301 · \`${HEAD_9AF9}\`` }] }).length === 0); + t('the chain walks BACK over several hops, oldest first', regenChainToHead({ headSha: 'cccccccc', prComments: [PROV(HEAD_9AF9, 'bbbbbbbb'), PROV('bbbbbbbb', 'cccccccc')] })?.map((h) => h.from).join() === `${HEAD_9AF9},bbbbbbbb`); + t('⛔ two hops arriving at ONE head carry no chain — ambiguity is never ranked', regenChainToHead({ headSha: NEW_HEAD, prComments: [PROV(HEAD_9AF9), PROV('bbbbbbbb')] }) === null); + t('⭐ the SAME hop on BOTH carriers is ONE hop, ⛔ not ambiguity — the dual-carrier habit this file trains must not kill the carry', regenChainToHead({ headSha: NEW_HEAD, prComments: [PROV()], cardComments: [PROV(HEAD_9AF9, NEW_HEAD, 3301, 3351)] })?.length === 1); + t('…and it CARRIES end to end from there, so the pair is CLEAN rather than 重挂', (() => { const dual = moved([RECORD_ON_9AF9, PROV()], GIT_EMPTY, { cardComments: [CLAIM('Clause-②: yes'), PROV(HEAD_9AF9, NEW_HEAD, 3301, 3351)] }); return gateBindingState(dual).state === 'completed' && locateReviewOfRecord(dual).state === 'found' && pairRows(dual).length === 0; })()); + t('⛔ a thread with no line carries none, so today\'s rule is untouched where nobody claims the exception', regenChainToHead(moved([RECORD_ON_9AF9])) === null && gateBindingState(moved([RECORD_ON_9AF9])).state === 'moved-after-clear'); + // the tree test — and it reads COMMITTED trees, never the working one + t('⭐ an EMPTY non-`merge=os-regen` diff CARRIES the record: the pair reads COMPLETED, ⛔ not 重挂', gateBindingState(CARRIED()).state === 'completed'); + t('…and a generated path DID move: the test named two COMMITS and read `.gitattributes` out of the new one', GIT_SEEN.includes(`diff -z --name-only ${HEAD_9AF9} ${NEW_HEAD}`) && GIT_SEEN.includes(`check-attr --source ${NEW_HEAD} -z merge --stdin`), GIT_SEEN.join(' | ')); + t('…so the record is FOUND on the new head, naming the head it actually judged and the hop count', (() => { const r = locateReviewOfRecord(CARRIED()); return r.state === 'found' && r.carriedFrom.startsWith(HEAD_9AF9) && r.carriedHops === 1; })()); + t('…and the pair reads CLEAN overall — no C3 row, no C6 row, nothing UNJUDGED', pairRows(CARRIED()).length === 0 && pairUnjudged(CARRIED()) === null, JSON.stringify(pairRows(CARRIED()).map((r) => r.code))); + t('⛔ a HAND-WRITTEN path in the same range certifies nothing — an ordinary 重挂, and the row says which path', gateBindingState(moved([RECORD_ON_9AF9, PROV()], GIT_HAND)).state === 'moved-after-clear' && says(c3DeclaredYesUngated(moved([RECORD_ON_9AF9, PROV()], GIT_HAND)), 'scripts/pm/x.mjs')); + t('…and it says the line being PRESENT decided nothing', says(c3DeclaredYesUngated(moved([RECORD_ON_9AF9, PROV()], GIT_HAND)), 'does NOT certify') && says(c3DeclaredYesUngated(moved([RECORD_ON_9AF9, PROV()], GIT_HAND)), 'committed trees')); + t('⛔ a chain whose hops name DIFFERENT records certifies nothing', regenCarry(moved([RECORD_ON_9AF9, PROV(HEAD_9AF9, 'bbbbbbbb', 3301), PROV('bbbbbbbb', NEW_HEAD, 9999)], GIT_EMPTY)).state === 'refused'); + t('⛔ a line naming a record the thread does not carry on that head leaves the record ABSENT', locateReviewOfRecord(moved([RECORD_ON_9AF9, PROV(HEAD_9AF9, NEW_HEAD, 9999)], GIT_EMPTY)).state === 'absent'); + // the environment that cannot answer — ⛔ never clean, in either reader + t('⭐ a tree this checkout cannot reach is a GAP: UNJUDGED, ⛔ never carried and ⛔ never clean', gateBindingState(moved([RECORD_ON_9AF9, PROV()], GIT_BLIND)).state === 'unreadable' && locateReviewOfRecord(moved([RECORD_ON_9AF9, PROV()], GIT_BLIND)).state === 'unreadable'); + t('…and the gap names the remedy, so the reader knows what to fetch', says(pairUnjudged(moved([RECORD_ON_9AF9, PROV()], GIT_BLIND)), 'git fetch origin pull/13864/head')); + t('⛔ a run holding NO git reader is a gap too — a claim nobody re-ran is not a certification', regenCarry(moved([RECORD_ON_9AF9, PROV()], undefined)).state === 'unreadable'); + t('the unexplained set is read from the ATTRIBUTE and the PR\'s own delta, ⛔ never from a path list spelled here', unexplainedPathsBetween(GIT_HAND, { from: HEAD_9AF9, to: NEW_HEAD, base: BASE }).join() === 'scripts/pm/x.mjs'); + // ⭐ the CARRY-OVER arm — 「every touched path is a generated artefact OR THE + // MERGE COMMIT'S OWN CARRY-OVER FROM MAIN」. Without it the exception never + // fires on the loop this card measured, because a merge-forward lists every + // path main carried over and a head-to-head diff alone reads them as hand-written. + t('⭐ (i) a MERGE-FORWARD carrying ANOTHER PR\'s hand-written path beside the regeneration is CARRIED', gateBindingState(withGit(GIT(CARRY_OVER))).state === 'completed' && locateReviewOfRecord(withGit(GIT(CARRY_OVER))).state === 'found'); + t('…and the pair is CLEAN on it — this is the loop the card measured, and it now exits', pairRows(withGit(GIT(CARRY_OVER))).length === 0 && pairUnjudged(withGit(GIT(CARRY_OVER))) === null); + t('⛔ (iii) the SAME path, hand-resolved so the new head no longer holds what main brought, is REFUSED', gateBindingState(withGit(GIT(HAND_RESOLVED))).state === 'moved-after-clear' && says(c3DeclaredYesUngated(withGit(GIT(HAND_RESOLVED))), 'AGENTS.md')); + t('⇒ the DELTA decides, ⛔ never the path name: one path, two specimens, two verdicts', unexplainedPathsBetween(GIT(CARRY_OVER), { from: HEAD_9AF9, to: NEW_HEAD, base: BASE }).length === 0 && unexplainedPathsBetween(GIT(HAND_RESOLVED), { from: HEAD_9AF9, to: NEW_HEAD, base: BASE }).join() === 'AGENTS.md'); + t('…and the delta is read ONCE PER HEAD against `merge-base BASE head`, ⛔ never once per path', GIT_SEEN.includes(`merge-base ${BASE} ${HEAD_9AF9}`) && GIT_SEEN.includes(`diff -z --name-only mb-${NEW_HEAD} ${NEW_HEAD}`), GIT_SEEN.slice(-4).join(' | ')); + t('⛔ (iv) a run naming NO base ref cannot tell main\'s carry-over from a hand edit — UNJUDGED, ⛔ never clean', withGit(GIT(CARRY_OVER), { baseRef: null }) && regenCarry(withGit(GIT(CARRY_OVER), { baseRef: null })).state === 'unreadable' && says(pairUnjudged(withGit(GIT(CARRY_OVER), { baseRef: null })), 'base ref')); + // -- #18141: the head sha's span holds the sha ALONE ----------------------- // // ★ The trap, both halves. `references/contract-review.md` :28 said only diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index 5b75964229a..c319805981c 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -554,11 +554,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ '⭐ #18701: the record lives on the PR or its card, and BOTH are read': 14, '⛔ #19036: the SIZE line at the queue — imported, per queued PR, fail-closed': 30, '⭐ #19344: the remedy names a path the ruleset actually offers': 5, + '⭐ the 2026-09-20 ruling: a certified PURE REGENERATION carries the record to the queued head': 11, }); // 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 @@ -857,6 +858,11 @@ export function recordVerdict({ pair, recognisers, cardNote = null }) { // ⛔ never quoted back — a refusal that quotes it lands the identifier in // one more artifact, which is the thing `AGENTS.md` forbids of a comment. servedIsIdentifier: located.served?.state === 'read' && recognisers.isModelIdentifierToken(located.served.value), + // ⭐ A record reached through the 纯重生成 carry names an OLDER head, so the + // clear says which head was reviewed and over how many certified hops: ⛔ a + // verdict may not deny its own evidence (#15406). + carriedFrom: located.carriedFrom ?? null, + carriedHops: located.carriedHops ?? 0, }; if (located.state === 'unsigned') return { state: 'unsigned', ...found }; if (!recognisers.servedTierStands(located.served)) return { state: 'below-tier', ...found }; @@ -1277,6 +1283,14 @@ export function renderGuardVerdict(verdict) { (entry.record.served?.stamps ? ` on a stamp control of ${entry.record.served.stamps.atTier}/${entry.record.served.stamps.total}.` : ' (no stamp control declared, which the rule permits).'), + ...(entry.record.carriedFrom + ? [ + ` ⭐ that record names head \`${String(entry.record.carriedFrom).slice(0, 12)}\`, carried forward over ` + + `${entry.record.carriedHops} certified PURE-REGENERATION hop(s) —`, + ' re-run on the COMMITTED trees by this build (`git diff --name-only`, no `merge=os-regen` path left),', + ' ⛔ never on the `Regen-provenance:` line being present (maintainer 2026-09-20).', + ] + : []), ' ⚠️ EXISTENCE and PROVENANCE, never the verdict: whether that record reads PASS is precondition ①', ' of the landing check and stays human. This leg measures what produced it, not what it concluded.', ); @@ -1516,7 +1530,7 @@ 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, fetchPull, fetchComments, loadRecognisers = loadRecordRecognisers, lifted = [] }) { +export async function runGuard({ event, rows, fetchReviews, fetchPull, fetchComments, loadRecognisers = loadRecordRecognisers, lifted = [], runGit = null, baseRef = null }) { const { governed, unattributed } = decomposeGovernedWork(rows); if (governed.length === 0 && unattributed.length === 0) { return guardVerdict({ event, governed, unattributed, apiCalls: 0, lifted }); @@ -1604,7 +1618,12 @@ export async function runGuard({ event, rows, fetchReviews, fetchPull, fetchComm // locations this leg READS are the locations `--template` STATES, because // both are this list. The cross-tool pin drives exactly this loop. const numbers = { pr: entry.pr, card: card.card }; - const pair = { pr: entry.pr, card: card.card, headSha: heads.get(entry.pr) ?? null }; + // ⭐ `runGit` and `baseRef` let the imported reader re-run the 纯重生成 + // test on two COMMITTED trees when this head moved past its record — + // the base being what separates the merge commit's carry-over from a hand + // edit. ⛔ Either one absent is a REFUSAL, never a pass: the reader + // answers `unreadable` and this leg refuses on it. + const pair = { pr: entry.pr, card: card.card, headSha: heads.get(entry.pr) ?? null, runGit, baseRef }; let unreadable = null; for (const thread of recognisers.threads) { const number = numbers[thread.number]; @@ -2078,8 +2097,8 @@ export function groupExitCode({ governed, size, carrier }) { // ── git (diff decomposition; zero API) ────────────────────────────────────── -function git(root, args) { - return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); +function git(root, args, input) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8', input, maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }); } /** Is `rev` an object this checkout actually has? A missing sha is a hard failure, never an empty diff. */ @@ -2453,7 +2472,8 @@ async function main() { const fetchLabels = makeLabelReader(reader); const fetchComments = makeCommentReader(reader); - const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPull, fetchComments, lifted }); + const runGit = (args, input) => git(repoRoot, args, input); + const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPull, fetchComments, lifted, runGit, baseRef: context.baseSha ?? null }); // The SIZE leg (#19036): every queued pull request, through the same pull // reader the governed leg reads heads with. `merge_group` only, '' on the // other leg, so the `pull_request` output is byte-identical to what it was. @@ -4054,6 +4074,8 @@ export async function selfTest() { loaderThrows = false, event = EVENT_MERGE_GROUP, pr = 70, + runGit = null, + baseRef = null, } = {}) => { tierApiCalls = 0; tierThreadsRead = []; @@ -4069,6 +4091,8 @@ export async function selfTest() { return n === pr ? comments : cardComments; }, loadRecognisers: async () => { if (loaderThrows) throw new Error('loader exploded'); return recognisers; }, + runGit, + baseRef, }); }; @@ -4417,6 +4441,50 @@ export async function selfTest() { governedPathsIn([REF_A]).length === 1 && governedPathsIn([RULES_PATH]).length === 1, ); + + // ⭐ Nothing is re-implemented here: the carry lives in the IMPORTED reader + // and this leg supplies only the git it reads two COMMITTED trees with. ⛔ Its + // absence REFUSES — a `Regen-provenance:` line certifies nothing by existing. + battery('⭐ the 2026-09-20 ruling: a certified PURE REGENERATION carries the record to the queued head'); + const REGEN_PROV = { id: 901, created_at: '2026-09-13T13:30:00Z', body: `Regen-provenance: 900 · \`${REF_OLD}\` → \`${REF_HEAD}\` · \`git diff --name-only\` → (empty)` }; + const onOldHead = [recordComment({ sha: REF_OLD.slice(0, 12) }), REGEN_PROV]; + const QBASE = 'b'.repeat(40); // the merge group's base sha, which is what main brought + const qgit = (spec) => (args) => { + if (args[0] === 'merge-base') return `mb-${args[2]}\n`; + if (args[0] === 'check-attr') return spec.attrs; + if (args[0] === 'rev-parse') return `${REF_OLD}\n`; + const [, , , a, b] = args; // diff -z --name-only A B + return a === REF_OLD && b === REF_HEAD ? spec.moved : (spec.own?.[b] ?? ''); + }; + const qRegen = { moved: 'packages/spec/api-surface/ui.txt\0', attrs: 'packages/spec/api-surface/ui.txt\0merge\0os-regen\0' }; + const qCarry = { moved: 'packages/spec/api-surface/ui.txt\0AGENTS.md\0', attrs: 'packages/spec/api-surface/ui.txt\0merge\0os-regen\0AGENTS.md\0merge\0unspecified\0', own: {} }; + const qHand = { moved: 'AGENTS.md\0', attrs: 'AGENTS.md\0merge\0unspecified\0', own: { [REF_HEAD]: 'AGENTS.md\0' } }; + // (ii) an edit SLIPPED IN beside the regeneration: a generated path moved, and + // so did one of this pull request's own, which its delta at the NEW head names. + const qSlipped = { moved: 'packages/spec/api-surface/ui.txt\0scripts/pm/x.mjs\0', attrs: 'packages/spec/api-surface/ui.txt\0merge\0os-regen\0scripts/pm/x.mjs\0merge\0unspecified\0', own: { [REF_HEAD]: 'scripts/pm/x.mjs\0' } }; + const gitPure = qgit(qRegen); + const gitHand = qgit(qSlipped); + const carried = await tierRun({ comments: onOldHead, runGit: gitPure, baseRef: QBASE }); + assert('⭐ a-record-on-an-OLDER-head-CARRIES-when-the-move-is-a-certified-pure-regeneration', carried.exitCode === EXIT_CLEAR && carried.entries[0].record.state === 'stands' && carried.entries[0].record.carriedHops === 1, JSON.stringify(carried.entries[0].record)); + assert('and-the-CLEAR-names-the-head-actually-reviewed-and-says-it-re-ran-on-the-committed-trees', /carried forward over 1 certified PURE-REGENERATION hop/.test(renderGuardVerdict(carried)) && /COMMITTED trees/.test(renderGuardVerdict(carried)) && /never on the `Regen-provenance:` line being present/.test(renderGuardVerdict(carried)), renderGuardVerdict(carried)); + const handMoved = await tierRun({ comments: onOldHead, runGit: gitHand, baseRef: QBASE }); + assert('⛔ (ii) an-EDIT-SLIPPED-IN-beside-the-regeneration-refuses-exactly-as-an-uncarried-old-head-does', handMoved.exitCode === EXIT_REFUSED_UNAPPROVED && handMoved.entries[0].record.state === 'absent'); + // ⭐ WHICH path is named by the reader that owns the reason; at the queue the + // refusal surfaces as an absent record, so the name is asserted at its source. + const { unexplainedPathsBetween } = await import(RECOGNISER_SOURCES.tier); + assert('…and-the-reader-NAMES-the-slipped-in-path-rather-than-the-generated-one-beside-it', unexplainedPathsBetween(gitHand, { from: REF_OLD, to: REF_HEAD, base: QBASE }).join() === 'scripts/pm/x.mjs', unexplainedPathsBetween(gitHand, { from: REF_OLD, to: REF_HEAD, base: QBASE }).join()); + const blindTree = await tierRun({ comments: onOldHead, runGit: () => { throw new Error('fatal: bad object'); }, baseRef: QBASE }); + assert('⛔ a-tree-this-build-cannot-reach-is-UNREADABLE-exit-4-never-clean', blindTree.exitCode === EXIT_REFUSED_UNREADABLE && blindTree.entries[0].record.state === 'unreadable'); + assert('⛔ and-a-run-with-NO-git-reader-refuses-too-the-line-alone-certifies-nothing', (await tierRun({ comments: onOldHead, baseRef: QBASE })).exitCode === EXIT_REFUSED_UNREADABLE); + // ⭐ the carry-over arm at the queue: the merge group's OWN base is what + // separates what main brought from what this pull request changed. + const carryOver = await tierRun({ comments: onOldHead, runGit: qgit(qCarry), baseRef: QBASE }); + assert('⭐ a-merge-forward-carrying-another-PRs-hand-written-path-beside-the-regeneration-CLEARS', carryOver.exitCode === EXIT_CLEAR && carryOver.entries[0].record.state === 'stands', JSON.stringify(carryOver.entries[0].record)); + const resolved = await tierRun({ comments: onOldHead, runGit: qgit(qHand), baseRef: QBASE }); + assert('⛔ the-SAME-path-hand-resolved-so-the-new-head-no-longer-holds-what-main-brought-REFUSES', resolved.exitCode === EXIT_REFUSED_UNAPPROVED && resolved.entries[0].record.state === 'absent'); + assert('⛔ and-a-group-whose-BASE-this-build-could-not-read-is-UNREADABLE-never-clean', (await tierRun({ comments: onOldHead, runGit: qgit(qCarry) })).exitCode === EXIT_REFUSED_UNREADABLE); + assert('⛔ CONTROL-the-ordinary-old-head-refusal-is-unmoved-where-no-line-claims-the-exception', (await tierRun({ comments: [recordComment({ sha: REF_OLD.slice(0, 12) })], runGit: gitPure, baseRef: QBASE })).exitCode === EXIT_REFUSED_UNAPPROVED); + assert('⛔ CONTROL-a-record-on-the-CURRENT-head-still-clears-without-reading-any-tree', (await tierRun({ comments: [recordComment()], runGit: () => { throw new Error('no tree may be read when the record is already on this head'); } })).exitCode === EXIT_CLEAR); // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so