From ed88b3d50214da5f33175277b252bbfce2c0ab9c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 01:59:46 +0000 Subject: [PATCH 1/4] feat(pm): a pure-regeneration head move keeps the contract-review record MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The maintainer ruled 2026-09-20 that a pure regeneration commit does not re-open an at-tier review record. The criterion is machine-read on the COMMITTED trees — the paths that moved between the recorded head and the new one, restricted to those carrying no `merge=os-regen` attribute, must be empty — and never a seat's own statement. The `Regen-provenance:` line is a pointer a later reader re-runs, not the evidence. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- .../pm-dispatch/references/contract-review.md | 2 + scripts/pm/check-clause2-carriers.mjs | 210 +++++++++++++++++- scripts/pm/check-governed-queue-guard.mjs | 35 ++- 3 files changed, 236 insertions(+), 11 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/contract-review.md b/.claude/skills/pm-dispatch/references/contract-review.md index 4abd75613a..50273cd615 100644 --- a/.claude/skills/pm-dispatch/references/contract-review.md +++ b/.claude/skills/pm-dispatch/references/contract-review.md @@ -18,6 +18,8 @@ - FAIL 同 PASS 剥双载体:同笔留卡上交接评论(引复审、独立性对、欠改);卡态与 assignee 不动。 - 重挂前先查裁决:闸门标签缺失 ⇒ 先 grep 卡评论找复审结论;`get_reviews` 读空 ≠ 未复审。 - PASS + 无标 + head 未动 = 已清标不是被剥;head 后移或无结论才重挂;清标缺引记录即半态。 +- 例外:纯重生成的 head 后移不重挂,原记录继续管;判据机器读已提交树 ⛔ 非席位自述。 +- 判据 = 两 head 间非 `merge=os-regen` 路径为空;PR 落 `Regen-provenance: 记录id · 旧head → 新head`。 ## 复核归属与资格(按面) diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index 0a8d767289..dd1410c154 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'; @@ -3116,6 +3116,172 @@ 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 — and on a +// generated-artefact-dense surface that is a loop the reviewed seat cannot +// exit: somebody else lands, baselines drift, the seat regenerates, the head +// moves, the record is owed again (measured 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 always stood for, nothing else moved. +// +// ⛔ 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. A reader +// that cannot reach both commits answers with a GAP and the pair is UNJUDGED, +// ⛔ never clean; a seat that writes the line and nothing else certified nothing. + +/** + * One hop, as a seat posts it: + * `Regen-provenance: · · → (empty)` + * + * Decoration is tolerated exactly as `REVIEWED_BY_LINE` tolerates it — a + * bullet, bold, backticked shas — because none of it changes which commits the + * line names. Everything after the second sha is the seat's own transcript and + * is deliberately UNREAD: a reader re-runs its own command rather than + * believing 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 — or `null` when none + * does. Several hops are ordinary: a pull request is re-synced once per drift. + * + * ⛔ Ambiguity is never resolved by picking one: two hops arriving at the same + * head end the walk, so a thread carrying a contradictory pair carries no chain + * at all — the refusing direction, which is the only one this may be wrong in. + */ +export function regenChainToHead(pair) { + const hops = regenProvenanceHops(pair); + 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 two COMMITTED trees: which moved paths carry no + * `merge=os-regen` attribute. Empty is the whole criterion. + * + * `--source ` reads `.gitattributes` out of THAT COMMIT rather than out of + * whatever the working tree holds, which is what keeps the entire reading on + * committed trees; `-z` on both calls because a path may hold a space, a quote + * or a colon and the parse must not be the weak link. + */ +export function handWrittenPathsBetween(runGit, from, to) { + const names = String(runGit(['diff', '-z', '--name-only', from, to])).split('\0').filter((p) => p !== ''); + if (names.length === 0) return []; + const f = String(runGit(['check-attr', '--source', to, '-z', 'merge', '--stdin'], names.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]); + return hand; +} + +/** + * The git reader the carry re-runs the ruled test with — this checkout, read + * only, and injectable so the self-test drives every branch offline. A failure + * is a GAP rather than a verdict: `check-attr --source` needs a git that has it, + * and a checkout that never fetched the recorded head cannot answer 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 because `gateBindingState` is asked several times per run + * and each miss costs two subprocesses per hop; the answer is a function of the + * pair and of two immutable commits, so it cannot go stale within a run. + * + * ⛔ FOUR states, and none of them may be folded: `none` (no line, today's rule + * unchanged), `refused` (a line that does NOT certify — the loudest case, and + * an ordinary re-hang), `unreadable` (the environment could not answer) and + * `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`] }; + } + for (const hop of chain) { + let hand; + try { + hand = handWrittenPathsBetween(runGit, hop.from, hop.to); + } catch (error) { + return { + state: 'unreadable', + gaps: [ + `the committed trees ${hop.from.slice(0, 10)}..${hop.to.slice(0, 10)} (${String(error?.message ?? error).split('\n')[0]}) — ` + + `fetch both commits (\`git fetch origin pull/${pair?.pr}/head\`, then \`git fetch origin ${hop.from}\`) and re-run`, + ], + }; + } + if (hand.length > 0) { + return { + state: 'refused', + reason: + `${hop.from.slice(0, 10)}→${hop.to.slice(0, 10)} moved ${hand.length} path(s) carrying no \`merge=os-regen\` ` + + `attribute (${hand.slice(0, 4).join(', ')}) — hand-written content moved, so this is not a 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 +3307,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 +3339,14 @@ 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) { + // ⭐ The ruled exception: a head move certified as a PURE REGENERATION + // leaves the record governing, so no re-hang is owed. ⛔ `unreadable` is + // folded into neither answer — a chain nobody could re-run 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 +3389,12 @@ 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.` + + (binding.carry?.state === 'refused' + ? ` ⚠️ A \`Regen-provenance:\` chain IS on the thread and it 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 +4517,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 +4526,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 +4542,22 @@ export function locateReviewOfRecord(pair) { ); const newest = latestMarkedComment(onHead.map(({ row }) => row), CONTRACT_REVIEW_HEADING_MARKER); if (!newest) { + // ⭐ The ruled exception, and it is consulted ONLY here — after the ordinary + // read found nothing, so this can turn an absence into a record and can + // never take one away. The second read is pinned to the carried head AND to + // the record id the chain names: a line pointing at a comment the thread + // does not carry on that head certifies nothing. Depth is one by + // construction, since the recursive call passes a carried 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 +6156,10 @@ 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, read by `regenCarry` alone. It rides the pair + // rather than a parameter so every reader of a pair — this file's rows + // and the queue guard's tier leg — reaches the same one mechanism. + runGit: REPO_GIT, // ⭐ 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 diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index 5b75964229..356367c15d 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -857,6 +857,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 must say 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 +1282,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 +1529,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 }) { const { governed, unattributed } = decomposeGovernedWork(rows); if (governed.length === 0 && unattributed.length === 0) { return guardVerdict({ event, governed, unattributed, apiCalls: 0, lifted }); @@ -1604,7 +1617,11 @@ 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` is what lets the imported reader re-run the 纯重生成 test on + // the two COMMITTED trees when this head moved past its record. ⛔ Its + // absence is never a pass: the reader answers `unreadable` and this leg + // REFUSES, exactly as it does for a thread it could not read. + const pair = { pr: entry.pr, card: card.card, headSha: heads.get(entry.pr) ?? null, runGit }; let unreadable = null; for (const thread of recognisers.threads) { const number = numbers[thread.number]; @@ -2078,8 +2095,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 +2470,15 @@ async function main() { const fetchLabels = makeLabelReader(reader); const fetchComments = makeCommentReader(reader); - const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPull, fetchComments, lifted }); + const verdict = await runGuard({ + event: context.event, + rows, + fetchReviews, + fetchPull, + fetchComments, + lifted, + runGit: (args, input) => git(repoRoot, args, input), + }); // 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. From 60c99d88c0ca237c454751086378543ccf55de32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:05:28 +0000 Subject: [PATCH 2/4] test(pm): pin the pure-regeneration carry in both readers' self-tests Seventeen cases in `check-clause2-carriers.mjs` and seven at the queue: the chain read off either thread, the multi-hop walk, the ambiguity that carries nothing, the empty tree that carries, the hand-written path that does not, the record id the line must name, and the unreachable tree that is UNJUDGED rather than clean in both readers. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- scripts/pm/check-clause2-carriers.mjs | 151 ++++++++++++---------- scripts/pm/check-governed-queue-guard.mjs | 46 ++++--- 2 files changed, 110 insertions(+), 87 deletions(-) diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index dd1410c154..a55f221cd9 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -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': 17, }); // 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 @@ -3118,33 +3120,30 @@ export function needsGateHistory(pair) { // ── the pure-regeneration carry (maintainer 2026-09-20 「纯重生成提交不需要开达档复核记录」) ── // -// A record binds to a head, so any push re-owes the review — and on a -// generated-artefact-dense surface that is a loop the reviewed seat cannot -// exit: somebody else lands, baselines drift, the seat regenerates, the head -// moves, the record is owed again (measured 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 always stood for, nothing else moved. +// 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 +// ⛔ 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. A reader -// that cannot reach both commits answers with a GAP and the pair is UNJUDGED, -// ⛔ never clean; a seat that writes the line and nothing else certified nothing. +// ⭐ 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. A reader that +// cannot reach both answers with a GAP and the pair is UNJUDGED, ⛔ never clean. /** * One hop, as a seat posts it: * `Regen-provenance: · · → (empty)` * - * Decoration is tolerated exactly as `REVIEWED_BY_LINE` tolerates it — a - * bullet, bold, backticked shas — because none of it changes which commits the - * line names. Everything after the second sha is the seat's own transcript and - * is deliberately UNREAD: a reader re-runs its own command rather than - * believing a pasted one. + * 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: a reader re-runs its own command rather than believing 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})`?/; @@ -3162,12 +3161,10 @@ export function regenProvenanceHops(pair) { const shaMeets = (a, b) => a.startsWith(b) || b.startsWith(a); /** - * The hops that chain BACK from this head, oldest first — or `null` when none - * does. Several hops are ordinary: a pull request is re-synced once per drift. - * - * ⛔ Ambiguity is never resolved by picking one: two hops arriving at the same - * head end the walk, so a thread carrying a contradictory pair carries no chain - * at all — the refusing direction, which is the only one this may be wrong in. + * 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) { const hops = regenProvenanceHops(pair); @@ -3187,12 +3184,10 @@ export function regenChainToHead(pair) { /** * The ruled test on two COMMITTED trees: which moved paths carry no - * `merge=os-regen` attribute. Empty is the whole criterion. - * - * `--source ` reads `.gitattributes` out of THAT COMMIT rather than out of - * whatever the working tree holds, which is what keeps the entire reading on - * committed trees; `-z` on both calls because a path may hold a space, a quote - * or a colon and the parse must not be the weak link. + * `merge=os-regen` attribute. Empty is the whole criterion. `--source ` + * reads `.gitattributes` out of THAT COMMIT rather than out of whatever the + * working tree holds, which is what keeps the whole reading on committed trees; + * `-z` on both calls because a path may hold a space, a quote or a colon. */ export function handWrittenPathsBetween(runGit, from, to) { const names = String(runGit(['diff', '-z', '--name-only', from, to])).split('\0').filter((p) => p !== ''); @@ -3204,10 +3199,10 @@ export function handWrittenPathsBetween(runGit, from, to) { } /** - * The git reader the carry re-runs the ruled test with — this checkout, read - * only, and injectable so the self-test drives every branch offline. A failure - * is a GAP rather than a verdict: `check-attr --source` needs a git that has it, - * and a checkout that never fetched the recorded head cannot answer at all. + * 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'] }); @@ -3216,18 +3211,14 @@ 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, each miss + * costs two subprocesses per hop, and two commits cannot change under a run. * - * Memoised per pair because `gateBindingState` is asked several times per run - * and each miss costs two subprocesses per hop; the answer is a function of the - * pair and of two immutable commits, so it cannot go stale within a run. - * - * ⛔ FOUR states, and none of them may be folded: `none` (no line, today's rule - * unchanged), `refused` (a line that does NOT certify — the loudest case, and - * an ordinary re-hang), `unreadable` (the environment could not answer) and - * `carried`. + * ⛔ 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) and `carried`. * - * @returns {{ state: 'none' } - * | { state: 'unreadable', gaps: string[] } + * @returns {{ state: 'none' } | { state: 'unreadable', gaps: string[] } * | { state: 'refused', reason: string } * | { state: 'carried', record: number, head: string, hops: number }} */ @@ -3249,25 +3240,16 @@ function computeRegenCarry(pair) { 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`] }; } for (const hop of chain) { + const span = `${hop.from.slice(0, 10)}..${hop.to.slice(0, 10)}`; let hand; try { hand = handWrittenPathsBetween(runGit, hop.from, hop.to); } catch (error) { - return { - state: 'unreadable', - gaps: [ - `the committed trees ${hop.from.slice(0, 10)}..${hop.to.slice(0, 10)} (${String(error?.message ?? error).split('\n')[0]}) — ` + - `fetch both commits (\`git fetch origin pull/${pair?.pr}/head\`, then \`git fetch origin ${hop.from}\`) and re-run`, - ], - }; + const why = String(error?.message ?? error).split('\n')[0]; + return { state: 'unreadable', gaps: [`the committed trees ${span} (${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: - `${hop.from.slice(0, 10)}→${hop.to.slice(0, 10)} moved ${hand.length} path(s) carrying no \`merge=os-regen\` ` + - `attribute (${hand.slice(0, 4).join(', ')}) — hand-written content moved, so this is not a pure regeneration`, - }; + return { state: 'refused', reason: `${span} moved ${hand.length} path(s) carrying no \`merge=os-regen\` attribute (${hand.slice(0, 4).join(', ')}) — hand-written content moved, so this is no pure regeneration` }; } } // Expanded through git so the carried head is spelled at least as fully as @@ -3340,9 +3322,8 @@ export function gateBindingState(pair) { return { state: 'unreadable', gaps: [`PR #${pair?.pr}'s head commit date`] }; } if (headMs > clearedMs) { - // ⭐ The ruled exception: a head move certified as a PURE REGENERATION - // leaves the record governing, so no re-hang is owed. ⛔ `unreadable` is - // folded into neither answer — a chain nobody could re-run is UNJUDGED. + // ⭐ 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 }; @@ -3391,8 +3372,7 @@ export function c3DeclaredYesUngated(pair) { 'the 重挂-owed state the recovery rule already names — 「head 后移或无结论才重挂」 — and ' + `the re-hang is a seat's act, not this script's.` + (binding.carry?.state === 'refused' - ? ` ⚠️ A \`Regen-provenance:\` chain IS on the thread and it does NOT certify this move: ${binding.carry.reason}. ` + - 'The 纯重生成 exception is decided on the committed trees, ⛔ never on the line being present.' + ? ` ⚠️ 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}` ); @@ -4542,12 +4522,10 @@ export function locateReviewOfRecord(pair, carriedHead = null) { ); const newest = latestMarkedComment(onHead.map(({ row }) => row), CONTRACT_REVIEW_HEADING_MARKER); if (!newest) { - // ⭐ The ruled exception, and it is consulted ONLY here — after the ordinary - // read found nothing, so this can turn an absence into a record and can - // never take one away. The second read is pinned to the carried head AND to - // the record id the chain names: a line pointing at a comment the thread - // does not carry on that head certifies nothing. Depth is one by - // construction, since the recursive call passes a carried head. + // ⭐ The ruled exception, consulted ONLY here — after the ordinary read + // found nothing — so it can turn 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: the recursive call passes that head. if (carriedHead === null) { const carry = regenCarry(pair); if (carry.state === 'unreadable') return { state: 'unreadable', gaps: carry.gaps }; @@ -8096,6 +8074,37 @@ 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 GIT_EMPTY = (args) => (GIT_SEEN.push(args.join(' ')), args[0] === 'diff' ? 'packages/spec/api-surface/data.txt\0' : args[0] === 'check-attr' ? 'packages/spec/api-surface/data.txt\0merge\0os-regen\0' : `${HEAD_9AF9}00\n`); + const GIT_HAND = (args) => (args[0] === 'diff' ? 'packages/spec/api-surface/data.txt\0scripts/pm/x.mjs\0' : 'packages/spec/api-surface/data.txt\0merge\0os-regen\0scripts/pm/x.mjs\0merge\0unspecified\0'); + const GIT_BLIND = () => { throw new Error(`fatal: bad object ${HEAD_9AF9}`); }; + const moved = (rows, runGit) => 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 }); + const CARRIED = () => moved([RECORD_ON_9AF9, PROV()], GIT_EMPTY); + // 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('⛔ 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 hand-written set is read from the attribute, ⛔ never from a path list spelled here', handWrittenPathsBetween(GIT_HAND, 'a1b2c3d', 'e4f5a6b').join() === 'scripts/pm/x.mjs'); + // -- #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 356367c15d..1daf625705 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': 7, }); // 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 @@ -858,8 +859,8 @@ export function recordVerdict({ pair, recognisers, cardNote = null }) { // 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 must say which head was reviewed and over how many certified hops. - // ⛔ A verdict may not deny its own evidence (#15406). + // 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, }; @@ -1617,10 +1618,9 @@ 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 }; - // ⭐ `runGit` is what lets the imported reader re-run the 纯重生成 test on - // the two COMMITTED trees when this head moved past its record. ⛔ Its - // absence is never a pass: the reader answers `unreadable` and this leg - // REFUSES, exactly as it does for a thread it could not read. + // ⭐ `runGit` lets the imported reader re-run the 纯重生成 test on two + // COMMITTED trees when this head moved past its record. ⛔ Its absence is + // never a pass: the reader answers `unreadable` and this leg REFUSES. const pair = { pr: entry.pr, card: card.card, headSha: heads.get(entry.pr) ?? null, runGit }; let unreadable = null; for (const thread of recognisers.threads) { @@ -2470,15 +2470,8 @@ async function main() { const fetchLabels = makeLabelReader(reader); const fetchComments = makeCommentReader(reader); - const verdict = await runGuard({ - event: context.event, - rows, - fetchReviews, - fetchPull, - fetchComments, - lifted, - runGit: (args, input) => git(repoRoot, args, input), - }); + const runGit = (args, input) => git(repoRoot, args, input); + const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPull, fetchComments, lifted, runGit }); // 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. @@ -4079,6 +4072,7 @@ export async function selfTest() { loaderThrows = false, event = EVENT_MERGE_GROUP, pr = 70, + runGit = null, } = {}) => { tierApiCalls = 0; tierThreadsRead = []; @@ -4094,6 +4088,7 @@ export async function selfTest() { return n === pr ? comments : cardComments; }, loadRecognisers: async () => { if (loaderThrows) throw new Error('loader exploded'); return recognisers; }, + runGit, }); }; @@ -4442,6 +4437,25 @@ 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 gitPure = (args) => (args[0] === 'diff' ? 'packages/spec/api-surface/ui.txt\0' : args[0] === 'check-attr' ? 'packages/spec/api-surface/ui.txt\0merge\0os-regen\0' : `${REF_OLD}\n`); + const gitHand = (args) => (args[0] === 'diff' ? 'AGENTS.md\0' : 'AGENTS.md\0merge\0unspecified\0'); + const carried = await tierRun({ comments: onOldHead, runGit: gitPure }); + 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 }); + assert('⛔ a-HAND-WRITTEN-path-in-the-range-refuses-exactly-as-an-uncarried-old-head-does', handMoved.exitCode === EXIT_REFUSED_UNAPPROVED && handMoved.entries[0].record.state === 'absent'); + const blindTree = await tierRun({ comments: onOldHead, runGit: () => { throw new Error('fatal: bad object'); } }); + 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 })).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 })).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 From dcffe20eee8194cb5bdd4a64a520f57befd233aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 02:46:07 +0000 Subject: [PATCH 3/4] =?UTF-8?q?fix(pm):=20the=20carry-over=20arm=20?= =?UTF-8?q?=E2=80=94=20a=20merge-forward's=20own=20paths=20are=20not=20a?= =?UTF-8?q?=20hand=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruled criterion reads 'every touched path is a generated artefact OR the merge commit's own carry-over from main'. Round 1 implemented only the first arm, so a head-to-head diff read every path main carried over as hand-written and the exception never fired on the loop the card measured. The second arm, machine-read on committed trees: a path this pull request never touched at EITHER head moved only because the base moved. The PR's own delta is read once per head (merge-base + one name-only diff), which is the same verdict as a per-path diff comparison at four calls instead of two per path. A run naming no base ref is UNJUDGED, never clean. The reference text returns to its ceiling: the exception compresses to one line, and the duplicated-and-drifted --pair exit table retires to the checker's own header, which is the authority on that detail. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- .../pm-dispatch/references/contract-review.md | 4 +- scripts/pm/check-clause2-carriers.mjs | 163 ++++++++++++------ scripts/pm/check-governed-queue-guard.mjs | 50 ++++-- 3 files changed, 150 insertions(+), 67 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/contract-review.md b/.claude/skills/pm-dispatch/references/contract-review.md index 50273cd615..8407a1591e 100644 --- a/.claude/skills/pm-dispatch/references/contract-review.md +++ b/.claude/skills/pm-dispatch/references/contract-review.md @@ -18,8 +18,7 @@ - FAIL 同 PASS 剥双载体:同笔留卡上交接评论(引复审、独立性对、欠改);卡态与 assignee 不动。 - 重挂前先查裁决:闸门标签缺失 ⇒ 先 grep 卡评论找复审结论;`get_reviews` 读空 ≠ 未复审。 - PASS + 无标 + head 未动 = 已清标不是被剥;head 后移或无结论才重挂;清标缺引记录即半态。 -- 例外:纯重生成的 head 后移不重挂,原记录继续管;判据机器读已提交树 ⛔ 非席位自述。 -- 判据 = 两 head 间非 `merge=os-regen` 路径为空;PR 落 `Regen-provenance: 记录id · 旧head → 新head`。 +- 例外:纯重生成 head 后移原记录继续管;判据机读已提交树 ⛔ 非自述;PR 落 provenance 行。 ## 复核归属与资格(按面) @@ -42,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 a55f221cd9..cffe69d007 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -863,7 +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': 17, + '⭐ the 2026-09-20 ruling: a pure-regeneration head move KEEPS the record, decided on the COMMITTED trees': 23, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -3130,20 +3130,18 @@ export function needsGateHistory(pair) { // ⛔ 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. A reader that -// cannot reach both answers with a GAP and the pair is UNJUDGED, ⛔ never clean. +// `--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: - * `Regen-provenance: · · → (empty)` - * + * 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 + * 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: a reader re-runs its own command rather than believing a pasted one. + * 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})`?/; @@ -3161,10 +3159,10 @@ export function regenProvenanceHops(pair) { 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. + * 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) { const hops = regenProvenanceHops(pair); @@ -3183,26 +3181,44 @@ export function regenChainToHead(pair) { } /** - * The ruled test on two COMMITTED trees: which moved paths carry no - * `merge=os-regen` attribute. Empty is the whole criterion. `--source ` - * reads `.gitattributes` out of THAT COMMIT rather than out of whatever the - * working tree holds, which is what keeps the whole reading on committed trees; - * `-z` on both calls because a path may hold a space, a quote or a colon. - */ -export function handWrittenPathsBetween(runGit, from, to) { - const names = String(runGit(['diff', '-z', '--name-only', from, to])).split('\0').filter((p) => p !== ''); - if (names.length === 0) return []; - const f = String(runGit(['check-attr', '--source', to, '-z', 'merge', '--stdin'], names.join('\0'))).split('\0'); + * 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]); - return hand; + 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. + * 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'] }); @@ -3211,12 +3227,10 @@ 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, each miss - * costs two subprocesses per hop, and two commits cannot change under a run. - * - * ⛔ 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) and `carried`. + * 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 } @@ -3239,17 +3253,23 @@ function computeRegenCarry(pair) { 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 = handWrittenPathsBetween(runGit, hop.from, hop.to); + 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} (${why}) — fetch both (\`git fetch origin pull/${pair?.pr}/head\`, \`git fetch origin ${hop.from}\`) and re-run`] }; + 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) carrying no \`merge=os-regen\` attribute (${hand.slice(0, 4).join(', ')}) — hand-written content moved, so this is no pure regeneration` }; + 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 @@ -3371,6 +3391,14 @@ export function c3DeclaredYesUngated(pair) { '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.` + + // ⭐ 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.` : '') + @@ -4522,10 +4550,10 @@ export function locateReviewOfRecord(pair, carriedHead = null) { ); 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 can turn 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: the recursive call passes that head. + // ⭐ 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 }; @@ -6134,10 +6162,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, read by `regenCarry` alone. It rides the pair - // rather than a parameter so every reader of a pair — this file's rows - // and the queue guard's tier leg — reaches the same one mechanism. + // 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 @@ -8079,11 +8111,32 @@ export async function selfTest() { 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 GIT_EMPTY = (args) => (GIT_SEEN.push(args.join(' ')), args[0] === 'diff' ? 'packages/spec/api-surface/data.txt\0' : args[0] === 'check-attr' ? 'packages/spec/api-surface/data.txt\0merge\0os-regen\0' : `${HEAD_9AF9}00\n`); - const GIT_HAND = (args) => (args[0] === 'diff' ? 'packages/spec/api-surface/data.txt\0scripts/pm/x.mjs\0' : 'packages/spec/api-surface/data.txt\0merge\0os-regen\0scripts/pm/x.mjs\0merge\0unspecified\0'); + 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) => 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 }); + 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); @@ -8103,7 +8156,17 @@ export async function selfTest() { 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 hand-written set is read from the attribute, ⛔ never from a path list spelled here', handWrittenPathsBetween(GIT_HAND, 'a1b2c3d', 'e4f5a6b').join() === 'scripts/pm/x.mjs'); + 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 ----------------------- // diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index 1daf625705..cbb1d26cea 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -554,7 +554,7 @@ 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': 7, + '⭐ the 2026-09-20 ruling: a certified PURE REGENERATION carries the record to the queued head': 10, }); // DELETING an entry silences that battery's floor exactly as effectively as @@ -1530,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 = [], runGit = null }) { +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 }); @@ -1618,10 +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 }; - // ⭐ `runGit` lets the imported reader re-run the 纯重生成 test on two - // COMMITTED trees when this head moved past its record. ⛔ Its absence is - // never a pass: the reader answers `unreadable` and this leg REFUSES. - const pair = { pr: entry.pr, card: card.card, headSha: heads.get(entry.pr) ?? null, runGit }; + // ⭐ `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]; @@ -2471,7 +2473,7 @@ async function main() { const fetchComments = makeCommentReader(reader); const runGit = (args, input) => git(repoRoot, args, input); - const verdict = await runGuard({ event: context.event, rows, fetchReviews, fetchPull, fetchComments, lifted, runGit }); + 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. @@ -4073,6 +4075,7 @@ export async function selfTest() { event = EVENT_MERGE_GROUP, pr = 70, runGit = null, + baseRef = null, } = {}) => { tierApiCalls = 0; tierThreadsRead = []; @@ -4089,6 +4092,7 @@ export async function selfTest() { }, loadRecognisers: async () => { if (loaderThrows) throw new Error('loader exploded'); return recognisers; }, runGit, + baseRef, }); }; @@ -4444,17 +4448,35 @@ export async function selfTest() { 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 gitPure = (args) => (args[0] === 'diff' ? 'packages/spec/api-surface/ui.txt\0' : args[0] === 'check-attr' ? 'packages/spec/api-surface/ui.txt\0merge\0os-regen\0' : `${REF_OLD}\n`); - const gitHand = (args) => (args[0] === 'diff' ? 'AGENTS.md\0' : 'AGENTS.md\0merge\0unspecified\0'); - const carried = await tierRun({ comments: onOldHead, runGit: gitPure }); + 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' } }; + const gitPure = qgit(qRegen); + const gitHand = qgit({ ...qHand, own: { [REF_HEAD]: 'AGENTS.md\0' } }); + 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 }); + const handMoved = await tierRun({ comments: onOldHead, runGit: gitHand, baseRef: QBASE }); assert('⛔ a-HAND-WRITTEN-path-in-the-range-refuses-exactly-as-an-uncarried-old-head-does', handMoved.exitCode === EXIT_REFUSED_UNAPPROVED && handMoved.entries[0].record.state === 'absent'); - const blindTree = await tierRun({ comments: onOldHead, runGit: () => { throw new Error('fatal: bad object'); } }); + 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 })).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 })).exitCode === EXIT_REFUSED_UNAPPROVED); + 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) ──── // From 62c76fee6ff6dd19ac7e067b29a45158a23f68d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 03:22:22 +0000 Subject: [PATCH 4/4] fix(pm): name the Regen-provenance token in the rule, and one hop on two carriers is one hop Three things the at-tier review named. (1) The rule line now carries the literal `Regen-provenance:` token, so a seat reading the governed text alone can write a line the reader matches; it stays one line at 110 bytes and the file stays at its ceiling. (2) regenChainToHead de-duplicates hops on record + from + to BEFORE the ambiguity test: the reader searches both carriers and the text trains the dual-carrier habit, so one hop posted on the PR and on its card arrived twice and ended the walk. Two DIFFERENT hops into one head still do. (3) The queue battery's duplicate specimen becomes a real slipped-in-edit case, and the path it names is asserted at the reader that owns the reason. Claude-Session: https://claude.ai/code/session_01Wnstp2kTth7sGXfr8fXypc Co-authored-by: Claude --- .../pm-dispatch/references/contract-review.md | 2 +- scripts/pm/check-clause2-carriers.mjs | 14 ++++++++++++-- scripts/pm/check-governed-queue-guard.mjs | 13 ++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.claude/skills/pm-dispatch/references/contract-review.md b/.claude/skills/pm-dispatch/references/contract-review.md index 8407a1591e..629350ee03 100644 --- a/.claude/skills/pm-dispatch/references/contract-review.md +++ b/.claude/skills/pm-dispatch/references/contract-review.md @@ -18,7 +18,7 @@ - FAIL 同 PASS 剥双载体:同笔留卡上交接评论(引复审、独立性对、欠改);卡态与 assignee 不动。 - 重挂前先查裁决:闸门标签缺失 ⇒ 先 grep 卡评论找复审结论;`get_reviews` 读空 ≠ 未复审。 - PASS + 无标 + head 未动 = 已清标不是被剥;head 后移或无结论才重挂;清标缺引记录即半态。 -- 例外:纯重生成 head 后移原记录继续管;判据机读已提交树 ⛔ 非自述;PR 落 provenance 行。 +- 例外:纯重生成 head 后移原记录继续管;判据机读已提交树;PR 落 `Regen-provenance:` 行。 ## 复核归属与资格(按面) diff --git a/scripts/pm/check-clause2-carriers.mjs b/scripts/pm/check-clause2-carriers.mjs index cffe69d007..9203b8a3c2 100644 --- a/scripts/pm/check-clause2-carriers.mjs +++ b/scripts/pm/check-clause2-carriers.mjs @@ -863,7 +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': 23, + '⭐ 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 @@ -3165,7 +3165,15 @@ const shaMeets = (a, b) => a.startsWith(b) || b.startsWith(a); * thread carries no chain — the only direction this may err in. */ export function regenChainToHead(pair) { - const hops = regenProvenanceHops(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 = []; @@ -8142,6 +8150,8 @@ export async function selfTest() { 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'); diff --git a/scripts/pm/check-governed-queue-guard.mjs b/scripts/pm/check-governed-queue-guard.mjs index cbb1d26cea..c319805981 100644 --- a/scripts/pm/check-governed-queue-guard.mjs +++ b/scripts/pm/check-governed-queue-guard.mjs @@ -554,7 +554,7 @@ 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': 10, + '⭐ 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 @@ -4459,13 +4459,20 @@ export async function selfTest() { 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({ ...qHand, own: { [REF_HEAD]: 'AGENTS.md\0' } }); + 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('⛔ a-HAND-WRITTEN-path-in-the-range-refuses-exactly-as-an-uncarried-old-head-does', handMoved.exitCode === EXIT_REFUSED_UNAPPROVED && handMoved.entries[0].record.state === 'absent'); + 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);