From dc9bc9b6e1fa20fc791db4d43b175d8b44e56753 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 17:33:41 +0000 Subject: [PATCH 1/2] feat(pm): derive a PR's file list three-dot in check-governed-merges (--pr / --branch) `--test` takes a caller-derived path list and nothing said how to derive it. The two obvious derivations disagree on any branch behind `main`: two-dot adds main's own newer commits, so the governance predicate can answer GOVERNED for paths a PR never touched. Adds `--pr ` (the API's changed-files list, three-dot by construction, paginated and proven against the PR's own count, renames contributing BOTH paths) and `--branch ` (merge-base three-dot, offline, `--no-renames` so a rename out of a governed path still reads as a hit). Each prints the derivation it used. Both refuse rather than fall back to two-dot when the derivation cannot be made. `--test`'s stdout is unchanged; the three-dot note goes to stderr so the enqueue hook's JSON reading is untouched. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude --- scripts/pm/check-governed-merges.mjs | 597 +++++++++++++++++++++++++-- 1 file changed, 563 insertions(+), 34 deletions(-) diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index fd8313662c..94b200b554 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -18,7 +18,10 @@ * node scripts/pm/check-governed-merges.mjs --repos objectstack,objectui * node scripts/pm/check-governed-merges.mjs --repo-root cloud=/srv/cloud * node scripts/pm/check-governed-merges.mjs --json # for round reports - * node scripts/pm/check-governed-merges.mjs --test AGENTS.md src/x.ts # pre-arm predicate + * node scripts/pm/check-governed-merges.mjs --test AGENTS.md src/x.ts # pre-arm predicate, list from the CALLER + * node scripts/pm/check-governed-merges.mjs --pr 16997 # same predicate, list DERIVED from the API + * node scripts/pm/check-governed-merges.mjs --pr objectstack-ai/objectui#42 # any governed repo + * node scripts/pm/check-governed-merges.mjs --branch claude/issue-17003-x # DERIVED offline, merge-base * node scripts/pm/check-governed-merges.mjs --self-test # offline, no network * * ## Exit codes — the refusal to read as clean, in one table @@ -51,6 +54,16 @@ * generator's output recomputed on the tree under test — every other reading * of those paths, and every other path, is unchanged. * + * `--pr ` and `--branch ` ask `--test`'s question about a list they + * DERIVE themselves, so they answer on `--test`'s codes byte-for-byte — 0 NOT + * governed, 3 GOVERNED — and give the derivation's own failure the code that + * already means "could not answer": + * 1 could not DERIVE the list — an unresolvable ref, a merge-base that + * cannot be computed, an unreachable API, a page walk that came back + * short. ⛔ A derivation that cannot be made is a REFUSAL, never a + * quieter answer: see the section below for why the fallback everyone + * reaches for is the defect itself. + * * `--self-test` mode answers on the sweep's 0 and 1, and adds the repo-wide third: * 0 every declared battery ran, and every case held. * 1 a FINDING about this file — a case failed, or a battery fell below its @@ -68,6 +81,60 @@ * 5 of that battery's 7 cases and prescribed a hunt through the generator * for deleted code that was never deleted. * + * ## The predicate is correct and its INPUT was undefined (#17003) + * + * `--test` takes a path list and nothing said how that list is to be derived. + * The two obvious derivations disagree on every branch that is behind `main`: + * + * git diff --name-only origin/main HEAD # TWO-dot — a SUPERSET + * git diff --name-only origin/main... HEAD # THREE-dot — what the PR changes + * + * The two-dot list also carries `main`'s OWN newer commits, rendered as + * reversions because the branch has not taken them. Measured on PR #16997: + * seven paths two-dot, two paths three-dot, five of them `main`'s. The + * superset fails safe in one direction and unsafe in the other — it can never + * HIDE a governed hit, but it can MANUFACTURE one, and a manufactured hit + * parks a correct ordinary PR under a rule whose reflex is to escalate rather + * than to re-derive the input. `skills/**` is regenerated by `gen:skill-refs` + * and moves on ordinary landings, so a branch a few hours old carrying that + * path in its two-dot diff is not a contrived case. + * + * Nothing in the predicate can detect this: a path list is a path list. So the + * derivation moved INTO the tool. `--pr ` reads the API's changed-files + * list, which is three-dot by construction; `--branch ` computes the + * merge base and diffs from it. Both PRINT the derivation they used, so the + * reading is self-describing the way this file's output already is, and + * `--test` keeps working with one line naming the form its caller owes it. + * + * ### Three decisions the derivations make, and why + * + * **A refusal, never a fallback.** `git merge-base origin/main ` exits 1 + * with EMPTY output on a shallow clone (measured: the CI container's own + * checkout cannot compute one for a fetched PR head). The recipe everyone + * writes — `git diff --name-only $(git merge-base origin/main REF) REF` — + * then degrades in the worst possible way: command substitution collapses to + * nothing, git reads the remaining single argument as a diff against the + * WORKING TREE, and the run exits 0 with a superset (measured: 650 paths where + * the branch had none). Every unresolvable input is therefore a stated refusal + * on exit 1. A wrong answer that reads like an answer is worse than no answer. + * + * **Renames contribute BOTH paths.** A rename OUT of a governed path is still + * a change to that path — moving `AGENTS.md` to `docs/AGENTS.md` edits the + * governed surface. The API says so directly (`status: 'renamed'` carries + * `previous_filename`), so both are read. Local git does NOT, by default: + * `git diff --name-only` with rename detection prints only the NEW path, so + * the same rename would read as ungoverned. `--branch` therefore diffs with + * `--no-renames`, which prints the old and the new path as a delete and an + * add — the same two paths the API reports. + * + * **A short page walk is a refusal too.** The changed-files endpoint pages at + * 100 (measured on PR #17076: 100 + 100 + 60 = 260, `Link rel="next"` present + * on the first two responses and absent on the third) and truncates at the + * API's own file ceiling. Counting a short page as the end is a heuristic that + * is wrong on any exact multiple of 100, so the walk follows `Link` and then + * proves itself against the PR's own `changed_files` count. A walk that cannot + * prove it collected the whole list refuses instead of answering on part of it. + * * ## The regime this audit belongs to (maintainer ruling, 2026-08-18) * * A human merge IS the review record for a governed PR. The seat put it as @@ -651,6 +718,12 @@ * on the local tree under test (a #11705 row runs that generator's own * `--check` once, ~3 s, for every path it owns in the diff) — still zero API * calls; every other `--test` run reads only the register in this file. + * `--branch` stays in that budget — two `git rev-parse`, one `git merge-base` + * and one `git diff`, all local. `--pr` is the one mode that must reach the + * API to answer at all, and it costs `1 + ceil(files / 100)` reads: one + * `GET /pulls/{n}` for the `changed_files` count the page walk proves itself + * against, then the pages. It reuses the sweep's channel chain and its proxy + * re-arm; it opens no second client and wants no second token. */ import { execFileSync, spawnSync } from 'node:child_process'; @@ -1209,6 +1282,218 @@ export function renderTestVerdict(verdict) { ); } +// ── deriving the path list (#17003): three-dot, or a refusal ──────────────── + +/** + * What `--test` says about its own INPUT. Printed on STDERR, deliberately: the + * enqueue hook (`.claude/hooks/guard-governed-enqueue.sh`) reads this mode's + * STDOUT as JSON, and a predicate that changes what it hands its consumers in + * order to warn its humans has broken something to say something. So `--test`'s + * stdout is byte-identical to the pre-#17003 form in both renderings, and the + * note travels beside it. + */ +export const CALLER_DERIVED_NOTE = + 'ℹ️ the paths above came from the CALLER — this predicate cannot see how they were derived.\n' + + ' Derive them THREE-dot (`git diff --name-only origin/main...HEAD`), ⛔ never two-dot\n' + + ' (`git diff --name-only origin/main HEAD`): on a branch behind main the two-dot list also\n' + + " carries main's OWN newer files, so it answers GOVERNED for paths this PR never touched\n" + + ' (#17003 — measured 7 paths two-dot vs 2 three-dot on PR #16997).\n' + + ' Better: let this script derive it — `--pr ` or `--branch `.'; + +/** + * `--pr`'s argument, as data. `16997` means this checkout's own repo; the + * qualified spelling names any of the governed repos, because the register is + * repo-agnostic and the PM audits five of them. Pure. + */ +export function parsePullTarget(value, selfSlug = null) { + const raw = typeof value === 'string' ? value.trim() : ''; + if (raw === '') return { error: '--pr wants a PR number (`--pr 16997`) or `/#`; it got nothing.' }; + const qualified = /^([\w.-]+\/[\w.-]+)#(\d+)$/.exec(raw); + if (qualified) return { slug: qualified[1], pull: Number(qualified[2]) }; + const bare = /^#?(\d+)$/.exec(raw); + if (bare) { + if (!selfSlug) { + return { + error: + `--pr ${raw} names no repo, and this checkout's origin does not parse to one — ` + + `spell it \`/#${bare[1]}\`.`, + }; + } + return { slug: selfSlug, pull: Number(bare[1]) }; + } + return { error: `--pr wants \`\` or \`/#\`; it got '${raw}'.` }; +} + +/** + * The `Link` header's `rel="next"` URL, or null. Pure. + * + * ⚠️ This, not a short page, is what ends the walk. "Stop when a page returns + * fewer than `per_page` rows" is wrong on every exact multiple of 100 — it + * reads the last full page as the last page and drops everything after it, + * silently and only on some PRs. + */ +export function linkNext(header) { + for (const part of String(header ?? '').split(',')) { + const m = /^\s*<([^>]+)>\s*;\s*(.+)$/.exec(part); + if (m && /\brel\s*=\s*"?next"?/.test(m[2])) return m[1]; + } + return null; +} + +/** + * The governed READING of an API changed-files page set: every `filename`, + * plus every `previous_filename` a rename carries. Pure. + * + * ⭐ The old path is not optional. A rename out of a governed path — say + * `AGENTS.md` to `docs/AGENTS.md` — changes the governed surface, and reading + * only the new name answers NOT governed for a diff that deletes a governed + * file. Both paths are read, and the pair is reported so the operator can see + * which hit came from a rename. + */ +export function pullPathsFrom(files) { + const list = Array.isArray(files) ? files : []; + const paths = []; + const renames = []; + const seen = new Set(); + const add = (p) => { + if (typeof p !== 'string' || p === '' || seen.has(p)) return; + seen.add(p); + paths.push(p); + }; + for (const file of list) { + add(file?.filename); + const previous = file?.previous_filename; + if (typeof previous === 'string' && previous !== '') { + add(previous); + renames.push({ from: previous, to: typeof file?.filename === 'string' ? file.filename : '(unnamed)' }); + } + } + return { paths, renames, entries: list.length }; +} + +/** + * Did the page walk collect the whole list? Pure — returns the refusal's + * reason, or null when the walk is provably complete. + * + * The proof is the PR's own `changed_files` count, because the endpoint has a + * ceiling of its own and a truncated list is a SUBSET: the one direction that + * can hide a governed hit outright. + */ +export function pullTruncationReason({ collected, changedFiles, pages, pageCap, morePages = false }) { + if (morePages) { + return ( + `the page walk hit its ${pageCap}-page cap with ${collected} file(s) collected and more pages still ` + + `pending. A truncated list is a SUBSET, and a subset can hide a governed hit outright.` + ); + } + if (typeof changedFiles !== 'number' || !Number.isFinite(changedFiles)) { + return ( + `the PR reports no \`changed_files\` count, so a walk that collected ${collected} file(s) over ` + + `${pages} page(s) cannot be proven whole. An unproven list is not a governed-surface answer.` + ); + } + if (collected === changedFiles) return null; + return ( + `the page walk collected ${collected} file(s) over ${pages} page(s) but the PR reports ${changedFiles}. ` + + `A list that does not match its own count is not one this predicate will answer on.` + ); +} + +/** The words every failed derivation prints. Pure, so `--self-test` pins them. */ +export function renderDerivationRefusal({ mode, reason, remedy = null }) { + return ( + `❌ cannot derive ${mode}'s file list — ${reason}\n` + + ` ⛔ Refusing rather than falling back to a two-dot diff (\`origin/main HEAD\`): on a branch\n` + + ` behind main that list carries main's own newer files, so this predicate would answer\n` + + ` GOVERNED for paths the PR never touched (#17003). A wrong answer is worse than none.` + + (remedy ? `\n Remedy: ${remedy}` : '') + ); +} + +/** + * `--branch `, offline: the three-dot path list, or a refusal. Pure given + * `run`, which takes a git argv and answers `{ ok, out, error }` — so + * `--self-test` pins every branch, including the ones a real checkout cannot + * be made to reach on demand. + * + * ⚠️ Each input is resolved and CHECKED before the next one is spent. An empty + * merge base is the whole reason this function exists: `git merge-base` exits + * 1 with no output on a shallow clone, and the shell recipe that reads it + * inline then diffs against the working tree and exits 0 with a superset. + */ +export function deriveBranchPaths({ ref, base = 'origin/main', run }) { + const refuse = (reason, remedy) => ({ ok: false, reason, remedy }); + const commit = (spec) => run(['rev-parse', '--verify', '--quiet', `${spec}^{commit}`]); + + const baseRead = commit(base); + if (!baseRead.ok || !isObjectId(baseRead.out)) { + return refuse( + `\`${base}\` does not resolve to a commit in this checkout${baseRead.error ? ` (${baseRead.error})` : ''}.`, + `\`git fetch origin main\`, then re-run. ⛔ Do not substitute a local \`main\` — it is not what the PR merges into.`, + ); + } + const refRead = commit(ref); + if (!refRead.ok || !isObjectId(refRead.out)) { + return refuse( + `\`${ref}\` does not resolve to a commit in this checkout${refRead.error ? ` (${refRead.error})` : ''}.`, + `Fetch the branch (\`git fetch origin ${ref}\`) or name one this checkout has.`, + ); + } + const mergeBase = run(['merge-base', baseRead.out, refRead.out]); + if (!mergeBase.ok || !isObjectId(mergeBase.out)) { + return refuse( + `\`git merge-base ${base} ${ref}\` computed nothing` + + `${mergeBase.error ? ` (${mergeBase.error})` : ' (it exited non-zero with empty output)'} — ` + + `a shallow clone or unrelated histories. There is no three-dot list to take.`, + `\`git fetch --deepen 200 origin main\` (or \`--unshallow\`) until the two histories meet, then re-run.`, + ); + } + // ⛔ --no-renames on purpose: with rename detection git prints ONLY the new + // path, so a rename out of a governed path would read as ungoverned. Without + // it the rename is a delete plus an add — the same two paths the API reports. + const diff = run(['diff', '--name-only', '--no-renames', mergeBase.out, refRead.out]); + if (!diff.ok) { + return refuse(`\`git diff\` failed against the merge base ${mergeBase.out.slice(0, 10)} (${diff.error}).`, null); + } + const paths = diff.out.split('\n').map((l) => l.trim()).filter((l) => l !== ''); + return { + ok: true, + paths, + derivation: { + kind: 'branch', + base, + baseSha: baseRead.out, + ref, + refSha: refRead.out, + mergeBase: mergeBase.out, + command: `git diff --name-only --no-renames ${mergeBase.out.slice(0, 10)} ${refRead.out.slice(0, 10)}`, + count: paths.length, + }, + }; +} + +/** The self-describing line every derived reading prints above its verdict. Pure. */ +export function renderDerivation(derivation) { + if (derivation?.kind === 'pull') { + const renameLines = (derivation.renames ?? []).map( + (r) => ` renamed — BOTH paths read, the old one included: ${r.from} → ${r.to}`, + ); + return [ + `derived from GET ${derivation.endpoint} (three-dot by construction): ${derivation.count} path(s) ` + + `from ${derivation.entries} changed file(s), over ${derivation.pages} page(s).`, + ...renameLines, + ].join('\n'); + } + if (derivation?.kind === 'branch') { + return ( + `derived from \`${derivation.command}\` (three-dot): ${derivation.count} path(s).\n` + + ` ${derivation.base} = ${derivation.baseSha.slice(0, 10)}, ${derivation.ref} = ${derivation.refSha.slice(0, 10)}, ` + + `merge-base = ${derivation.mergeBase.slice(0, 10)}.` + ); + } + return 'derived from an unnamed source — ⛔ do not act on this reading.'; +} + /** * The #11705 verdict, pure so `--self-test` pins every branch offline. Inputs: * the hit `path`, its register `entry`, and `run` — what the generator answered @@ -1475,6 +1760,25 @@ function git(root, args) { return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); } +/** + * The same call as `git()`, with the failure as DATA — `{ ok, out, error }`, + * `out` already trimmed. `deriveBranchPaths` needs every leg's exit status, + * and `git rev-parse --verify --quiet` reports a missing ref by exiting 1 with + * empty output, which a throwing helper turns into an exception carrying + * nothing an operator can read. + */ +function tryGit(root, args) { + try { + return { ok: true, out: git(root, args).trim(), error: '' }; + } catch (error) { + // git's own diagnosis or NOTHING. `error.message` here is node's wrapper + // ("Command failed: git merge-base "), which says only that the + // call failed — a caller quoting it prints two raw shas where a reason + // belongs. An empty string is honest; the failure itself is `ok: false`. + return { ok: false, out: '', error: String(error?.stderr ?? '').trim().split('\n')[0] }; + } +} + /** * Why a repo with a truncated history is UNAUDITED rather than swept. Pure, so * `--self-test` pins the words: this reason is the only thing standing between @@ -2003,6 +2307,114 @@ async function fetchPullAttribution({ apiUrl }, slug, pull, channels) { return { attribution: null, channel: null, failure: failures.join('; ') }; } +/** One JSON read over one channel, with the `Link` header the walk needs. */ +async function fetchJsonOver(fetchImpl, url, channel) { + let res; + try { + res = await fetchImpl(url, { + headers: { accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', ...channel.headers }, + }); + } catch (error) { + return { ok: false, reason: `${channel.name}: request failed (${error?.message ?? error})` }; + } + if (!res.ok) return { ok: false, reason: `${channel.name}: HTTP ${res.status}` }; + let body; + try { + body = await res.json(); + } catch (error) { + return { ok: false, reason: `${channel.name}: the response was not JSON (${error?.message ?? error})` }; + } + return { ok: true, body, link: typeof res.headers?.get === 'function' ? res.headers.get('link') : null }; +} + +/** How many pages the changed-files walk will follow before it refuses. */ +export const PULL_FILES_PAGE_CAP = 40; + +/** + * `--pr`'s derivation (#17003): the PR's changed files, over the sweep's own + * channel chain. `fetchImpl` is injected so `--self-test` pins the page walk, + * the rename reading and the truncation refusal offline. + * + * ⚠️ The channel is chosen ONCE, on the `GET /pulls/{n}` that reads the + * `changed_files` count, and every page is then read over that same channel. A + * walk that fell through to the next channel mid-list would be splicing two + * readings — taken at different instants, possibly at different permissions — + * into one list and calling it the PR's. + */ +export async function fetchPullFiles({ apiUrl, slug, pull, channels, fetchImpl = fetch, pageCap = PULL_FILES_PAGE_CAP }) { + const pullUrl = `${apiUrl}/repos/${slug}/pulls/${pull}`; + const failures = []; + let chosen = null; + let head = null; + for (const channel of channels) { + const read = await fetchJsonOver(fetchImpl, pullUrl, channel); + if (read.ok) { + chosen = channel; + head = read.body; + break; + } + failures.push(read.reason); + } + if (!chosen) { + return { + ok: false, + reason: `no channel could read ${slug}#${pull} — channels tried: ${failures.join('; ')}.`, + remedy: 'Derive the list from a local checkout instead: `--branch `. It needs no network at all.', + }; + } + const changedFiles = typeof head?.changed_files === 'number' ? head.changed_files : null; + + const files = []; + let pages = 0; + let next = `${pullUrl}/files?per_page=100`; + while (next && pages < pageCap) { + const read = await fetchJsonOver(fetchImpl, next, chosen); + if (!read.ok) { + return { + ok: false, + reason: `page ${pages + 1} of ${slug}#${pull}'s changed files did not read — ${read.reason}.`, + remedy: 'A partial list is a SUBSET, and a subset can hide a governed hit. Re-run, or use `--branch `.', + }; + } + if (!Array.isArray(read.body)) { + return { ok: false, reason: `page ${pages + 1} of ${slug}#${pull}'s changed files answered no list.`, remedy: null }; + } + files.push(...read.body); + pages += 1; + next = linkNext(read.link); + } + const truncation = pullTruncationReason({ + collected: files.length, + changedFiles, + pages, + pageCap, + morePages: Boolean(next) && pages >= pageCap, + }); + if (truncation) { + return { + ok: false, + reason: truncation, + remedy: 'Derive the list from a local checkout instead: `--branch `, which reads the whole diff at once.', + }; + } + const { paths, renames, entries } = pullPathsFrom(files); + return { + ok: true, + paths, + derivation: { + kind: 'pull', + slug, + pull, + endpoint: `/repos/${slug}/pulls/${pull}/files`, + channel: chosen.id, + pages, + entries, + count: paths.length, + renames, + }, + }; +} + /** * The per-run NAMED fallback lines (#9619): one line per repo+reason group * naming which channels were tried and what each answered — replacing the @@ -2278,6 +2690,40 @@ export function renderReport({ window, repos, scanned, entries, lookups, sweepCo const invokedDirectly = isEntrypoint(import.meta.url); +/** The tree a provenance recompute — and `--branch`'s git — runs against. */ +function rootFromArgs(args) { + const rootIdx = args.indexOf('--root'); + return resolve(rootIdx > -1 && args[rootIdx + 1] ? args[rootIdx + 1] : resolve(scriptDir, '..', '..')); +} + +/** + * The verdict half, shared by every mode that asks the register a question: + * `--test` with the caller's list, `--pr` and `--branch` with one they derived. + * + * ⭐ ONE emitter is the point of #17003's fix. The answer must not depend on + * how the list was obtained — a derivation that also reworded the verdict would + * leave two readings of the same register in circulation, which is the shape + * the card is about. `--self-test` pins the identity end to end. + */ +async function emitVerdict(paths, args, derivation = null) { + let verdict = testVerdict(paths); + // The provenance-aware exceptions (#9866 + #11705): recompute only when a + // registered path is actually among the hits, so every other run stays the + // zero-git, zero-cost read it always was. The driver applies each row's + // #11084 co-edit fence before spending anything — a diff that edits a + // generator alongside its artifact would otherwise certify itself. + const hitsByEntry = groupHitsByException(verdict.hitPaths); + if (hitsByEntry.size > 0) { + verdict = applyGeneratedExceptions( + verdict, + await recomputeProvenanceFor(rootFromArgs(args), hitsByEntry, { allPaths: paths }), + ); + } + if (args.includes('--json')) console.log(JSON.stringify(derivation ? { ...verdict, derivation } : verdict, null, 2)); + else console.log(renderTestVerdict(verdict)); + return verdict.governed ? EXIT_TEST_GOVERNED : EXIT_TEST_NOT_GOVERNED; +} + async function runTestMode(args) { const i = args.indexOf('--test'); const paths = []; @@ -2290,31 +2736,130 @@ async function runTestMode(args) { if (paths.length === 0) { console.error( `❌ --test wants the PR's changed paths, and got none. An empty path list is a failure, never\n` + - ` a "not governed" answer. Derive the list rather than typing it, e.g.\n` + + ` a "not governed" answer. Derive the list rather than typing it — best of all, let this\n` + + ` script derive it and say how:\n` + + ` node scripts/pm/check-governed-merges.mjs --pr \n` + + ` node scripts/pm/check-governed-merges.mjs --branch \n` + ` node scripts/pm/check-governed-merges.mjs --test $(gh pr diff --name-only )`, ); return EXIT_CANNOT_SWEEP; } - let verdict = testVerdict(paths); - // The provenance-aware exceptions (#9866 + #11705): recompute only when a - // registered path is actually among the hits, so every other `--test` run - // stays the zero-git, zero-cost read it always was. The driver applies each - // row's #11084 co-edit fence before spending anything — a diff that edits a - // generator alongside its artifact would otherwise certify itself. - const hitsByEntry = groupHitsByException(verdict.hitPaths); - if (hitsByEntry.size > 0) { - const rootIdx = args.indexOf('--root'); - const root = resolve(rootIdx > -1 && args[rootIdx + 1] ? args[rootIdx + 1] : resolve(scriptDir, '..', '..')); - verdict = applyGeneratedExceptions(verdict, await recomputeProvenanceFor(root, hitsByEntry, { allPaths: paths })); + // #17003: on STDERR, so this mode's STDOUT stays byte-identical in both + // renderings for the consumers that parse it (the enqueue hook reads the + // `--json` form). A predicate that changes what it hands its callers in + // order to warn its humans has broken something in order to say something. + console.error(CALLER_DERIVED_NOTE); + return await emitVerdict(paths, args); +} + +/** `--branch ` (#17003): the three-dot list from a local checkout, or a refusal. */ +async function runBranchMode(args) { + const ref = args[args.indexOf('--branch') + 1]; + if (!ref || ref.startsWith('--')) { + console.error('❌ --branch wants a git ref (`--branch claude/issue-17003-x`); it got none.'); + return EXIT_CANNOT_SWEEP; } - if (args.includes('--json')) console.log(JSON.stringify(verdict, null, 2)); - else console.log(renderTestVerdict(verdict)); - return verdict.governed ? EXIT_TEST_GOVERNED : EXIT_TEST_NOT_GOVERNED; + const root = rootFromArgs(args); + const derived = deriveBranchPaths({ ref, run: (argv) => tryGit(root, argv) }); + if (!derived.ok) { + console.error(renderDerivationRefusal({ mode: `--branch ${ref}`, reason: derived.reason, remedy: derived.remedy })); + return EXIT_CANNOT_SWEEP; + } + if (derived.paths.length === 0) { + console.error( + `❌ --branch ${ref} derived ZERO paths — it changes nothing against its merge base with ` + + `${derived.derivation.base}. An empty list is a failure, never a "not governed" answer.`, + ); + return EXIT_CANNOT_SWEEP; + } + if (!args.includes('--json')) console.log(renderDerivation(derived.derivation)); + return await emitVerdict(derived.paths, args, derived.derivation); +} + +/** `--pr ` (#17003): the API's changed-files list — three-dot by construction — or a refusal. */ +async function runPullMode(args) { + const raw = args[args.indexOf('--pr') + 1]; + const root = rootFromArgs(args); + const origin = tryGit(root, ['config', '--get', 'remote.origin.url']); + const selfSlug = origin.ok ? slugFromRemote(origin.out) : null; + const target = parsePullTarget(raw && !raw.startsWith('--') ? raw : '', selfSlug); + if (target.error) { + console.error(`❌ ${target.error}`); + return EXIT_CANNOT_SWEEP; + } + const derived = await fetchPullFiles({ + ...apiContext(process.env), + slug: target.slug, + pull: target.pull, + channels: attributionChannels(process.env), + }); + if (!derived.ok) { + console.error( + renderDerivationRefusal({ mode: `--pr ${target.slug}#${target.pull}`, reason: derived.reason, remedy: derived.remedy }), + ); + return EXIT_CANNOT_SWEEP; + } + if (derived.paths.length === 0) { + console.error( + `❌ ${target.slug}#${target.pull} reports no changed files at all. An empty list is a failure, ` + + `never a "not governed" answer.`, + ); + return EXIT_CANNOT_SWEEP; + } + if (!args.includes('--json')) console.log(renderDerivation(derived.derivation)); + return await emitVerdict(derived.paths, args, derived.derivation); } +/** + * The transport re-arm, shared by the sweep's attribution and `--pr`'s + * changed-files read (#9642): a proxied run whose fetch bypasses the proxy + * answers 401/403 on every channel and reads as a credential problem. The flag + * has to be set at process start, so re-exec. Returns the child's exit status, + * or null when this process should carry on and do the work itself. + */ +function rearmProxyOrNull(args, what, whatLower) { + const rearm = proxyRearmPlan({ + env: process.env, + execArgv: process.execArgv, + flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), + }); + if (!rearm.rearm) return null; + console.error(`ℹ️ re-exec with ${rearm.flag}: ${rearm.reason}. ${what} would otherwise fail on every channel.`); + // The proxy agent is experimental and says so once per run; the operator + // cannot act on that notice, so keep it out of the report where the node + // in use can silence it by code. + const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : []; + const child = spawnSync(process.execPath, [rearm.flag, ...quiet, scriptPath, ...args], { + stdio: 'inherit', + env: { ...process.env, [PROXY_REARM_GUARD]: '1' }, + }); + if (typeof child.status === 'number') return child.status; + console.error(`⚠️ could not re-exec with ${rearm.flag} (${child.error?.message ?? 'no exit status'}); continuing in-process — ${whatLower} may fail.`); + return null; +} + +/** The three ways to ask the register one question. Exactly one per run. */ +const PREDICATE_MODES = ['--test', '--pr', '--branch']; + async function main() { const args = process.argv.slice(2); + // ⛔ One list per run. Two mode flags would ask the same question about two + // different lists and print one verdict — the ambiguity #17003 is about. + const modes = PREDICATE_MODES.filter((m) => args.includes(m)); + if (modes.length > 1) { + console.error( + `❌ ${modes.join(' and ')} each name a DIFFERENT file list, and this predicate answers about one. ` + + `Run them separately.`, + ); + return EXIT_CANNOT_SWEEP; + } if (args.includes('--test')) return await runTestMode(args); + if (args.includes('--branch')) return await runBranchMode(args); + if (args.includes('--pr')) { + const rearmed = rearmProxyOrNull(args, 'The changed-files read', 'the changed-files read'); + if (rearmed !== null) return rearmed; + return await runPullMode(args); + } const argOf = (name) => { const i = args.indexOf(name); @@ -2385,24 +2930,8 @@ async function main() { // (a bad-arg run must not pay for a child process): a proxied run whose fetch // bypasses the proxy answers 401/403 on every channel and reads as a token // problem (#9642). The flag has to be set at process start, so re-exec. - const rearm = proxyRearmPlan({ - env: process.env, - execArgv: process.execArgv, - flagSupported: process.allowedNodeEnvironmentFlags.has(PROXY_FLAG), - }); - if (rearm.rearm) { - console.error(`ℹ️ re-exec with ${rearm.flag}: ${rearm.reason}. Attribution would otherwise fail on every channel.`); - // The proxy agent is experimental and says so once per run; the operator - // cannot act on that notice, so keep it out of the report where the node - // in use can silence it by code. - const quiet = process.allowedNodeEnvironmentFlags.has('--disable-warning') ? ['--disable-warning=UNDICI-EHPA'] : []; - const child = spawnSync(process.execPath, [rearm.flag, ...quiet, scriptPath, ...args], { - stdio: 'inherit', - env: { ...process.env, [PROXY_REARM_GUARD]: '1' }, - }); - if (typeof child.status === 'number') return child.status; - console.error(`⚠️ could not re-exec with ${rearm.flag} (${child.error?.message ?? 'no exit status'}); continuing in-process — attribution may fail.`); - } + const rearmed = rearmProxyOrNull(args, 'Attribution', 'attribution'); + if (rearmed !== null) return rearmed; const entries = []; let scanned = 0; From efde83ac1ad4567da0a42b106e8a720d22c9d4e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 17:37:42 +0000 Subject: [PATCH 2/2] test(pm): pin the three-dot derivation, its refusals and the card's reproduction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new self-test battery (43 cases): the Link page walk, a rename reaching the predicate as both of its paths, a walk the PR's own count contradicts, the channel chosen once and never spliced mid-walk, every --branch leg on an injected git, and the card's own reproduction run end to end on a real repo — a branch behind a main that has since touched a governed path answers GOVERNED two-dot and NOT governed three-dot, and the verdict is byte-identical through --branch and through --test on the same list. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude --- scripts/pm/check-governed-merges.mjs | 292 ++++++++++++++++++++++++++- 1 file changed, 289 insertions(+), 3 deletions(-) diff --git a/scripts/pm/check-governed-merges.mjs b/scripts/pm/check-governed-merges.mjs index 94b200b554..c1e519ffbe 100644 --- a/scripts/pm/check-governed-merges.mjs +++ b/scripts/pm/check-governed-merges.mjs @@ -727,7 +727,7 @@ */ import { execFileSync, spawnSync } from 'node:child_process'; -import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -776,11 +776,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ '#11705 end to end, against the REAL generator': 7, "the live battery's prerequisite, and the floor it was misread as": 17, '⭐ #15406: the sweep row names the register it does not recompute': 10, + '⭐ #17003: the list is DERIVED three-dot, or refused': 43, }); // 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 = 24; +const SELF_TEST_BATTERY_FLOOR = 25; // 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 @@ -4549,6 +4550,291 @@ async function selfTest() { plainHead, ); + // ── ⭐ #17003: the list is DERIVED three-dot, or the run refuses ────────── + // + // The predicate was correct and its INPUT was undefined. Every case here is + // about the list ARRIVING right — the API reading, the local reading, the + // refusals that must never soften into a two-dot superset, and the identity + // that keeps ONE register answer in circulation however the list was got. + battery('⭐ #17003: the list is DERIVED three-dot, or refused'); + + // The page walk's terminator. Real headers, measured on PR #17076 (260 + // files): the first two responses carry `rel="next"`, the third does not. + const linkPage1 = + '; rel="next", ' + + '; rel="last"'; + const linkPage3 = + '; rel="prev", ' + + '; rel="first"'; + assert('the-walk-follows-rel-next-while-one-is-offered', + linkNext(linkPage1) === 'https://api.github.com/repositories/1136691870/pulls/17076/files?per_page=100&page=2', + String(linkNext(linkPage1))); + assert('and-the-last-page-offers-none-so-the-walk-ends', + linkNext(linkPage3) === null && linkNext(null) === null && linkNext('') === null, String(linkNext(linkPage3))); + assert('a-rel-last-or-rel-prev-is-never-mistaken-for-a-rel-next', + linkNext('; rel="last"') === null && linkNext('; rel="prev"') === null); + + // ⭐ A rename contributes BOTH paths. Reading only `filename` answers NOT + // governed for a diff that moves AGENTS.md off the governed surface — the + // one direction a governance predicate must never fail in. + const renamedOut = [{ status: 'renamed', filename: 'docs/AGENTS.md', previous_filename: 'AGENTS.md' }]; + const renameRead = pullPathsFrom(renamedOut); + assert('a-rename-yields-the-new-path-AND-the-old-one', + renameRead.paths.join() === 'docs/AGENTS.md,AGENTS.md' && renameRead.entries === 1, JSON.stringify(renameRead)); + assert('⭐ a-rename-OUT-of-a-governed-path-is-still-a-change-TO-that-path', + testVerdict(renameRead.paths).governed === true && testVerdict(['docs/AGENTS.md']).governed === false, + JSON.stringify(testVerdict(renameRead.paths).hitPaths)); + assert('and-the-pair-is-reported-so-the-operator-sees-which-hit-came-from-a-rename', + renameRead.renames.length === 1 && renameRead.renames[0].from === 'AGENTS.md' && renameRead.renames[0].to === 'docs/AGENTS.md', + JSON.stringify(renameRead.renames)); + assert('an-ordinary-page-set-carries-no-rename-and-no-duplicate-paths', + pullPathsFrom([{ filename: 'a.ts' }, { filename: 'a.ts' }, { filename: 'b.ts' }]).paths.join() === 'a.ts,b.ts'); + + // A short list is a SUBSET — the one direction that can hide a hit outright. + assert('a-walk-that-matches-the-PRs-own-count-is-proven-whole', + pullTruncationReason({ collected: 260, changedFiles: 260, pages: 3, pageCap: 40 }) === null); + assert('a-walk-that-does-not-match-that-count-refuses-and-names-both-numbers', + /collected 100 file\(s\) over 1 page\(s\) but the PR reports 260/.test( + String(pullTruncationReason({ collected: 100, changedFiles: 260, pages: 1, pageCap: 40 })))); + assert('a-PR-that-reports-no-count-cannot-prove-the-walk-so-it-refuses-too', + /cannot be proven whole/.test(String(pullTruncationReason({ collected: 5, changedFiles: null, pages: 1, pageCap: 40 })))); + assert('and-a-walk-still-pending-pages-at-the-cap-refuses-on-the-cap-itself', + /hit its 40-page cap/.test(String(pullTruncationReason({ collected: 4000, changedFiles: 4000, pages: 40, pageCap: 40, morePages: true })))); + + // `--pr`'s argument. The qualified spelling exists because the register is + // repo-agnostic and the PM audits five repos from one checkout. + assert('a-bare-number-means-this-checkouts-own-repo', + JSON.stringify(parsePullTarget('16997', 'objectstack-ai/objectstack')) === JSON.stringify({ slug: 'objectstack-ai/objectstack', pull: 16997 })); + assert('a-qualified-spelling-names-any-governed-repo', + JSON.stringify(parsePullTarget('objectstack-ai/objectui#42', 'objectstack-ai/objectstack')) === JSON.stringify({ slug: 'objectstack-ai/objectui', pull: 42 })); + assert('a-bare-number-with-no-readable-origin-is-an-error-never-a-guessed-repo', + typeof parsePullTarget('42', null).error === 'string' && parsePullTarget('42', null).slug === undefined); + assert('and-nonsense-is-an-error-never-a-silent-zero', + typeof parsePullTarget('main', 'o/r').error === 'string' && typeof parsePullTarget('', 'o/r').error === 'string'); + + // `--branch`, every leg, on an injected git. Each refusal is asserted for the + // words a reader acts on — and for what it must NEVER say. + const gitScript = (answers) => (argv) => { + for (const [match, answer] of answers) if (argv.join(' ').includes(match)) return answer; + return { ok: false, out: '', error: 'unexpected git call: ' + argv.join(' ') }; + }; + const ok40 = (sha) => ({ ok: true, out: sha, error: '' }); + const A = 'a'.repeat(40); + const B = 'b'.repeat(40); + const M = 'c'.repeat(40); + const noBase = deriveBranchPaths({ ref: 'feature', run: gitScript([['rev-parse --verify --quiet origin/main', { ok: false, out: '', error: '' }]]) }); + assert('an-unfetched-origin-main-refuses-and-says-to-fetch-it', + noBase.ok === false && /does not resolve to a commit/.test(noBase.reason) && /git fetch origin main/.test(noBase.remedy), JSON.stringify(noBase)); + const noRef = deriveBranchPaths({ ref: 'ghost', run: gitScript([ + ['rev-parse --verify --quiet origin/main', ok40(A)], + ['rev-parse --verify --quiet ghost', { ok: false, out: '', error: '' }], + ]) }); + assert('a-ref-this-checkout-does-not-have-refuses-rather-than-diffing-something-else', + noRef.ok === false && /`ghost` does not resolve/.test(noRef.reason), JSON.stringify(noRef)); + // ⭐ The measured failure this whole mode exists for: `git merge-base` exits + // 1 with EMPTY output on a shallow clone, and the shell recipe that reads it + // inline then diffs against the WORKING TREE and exits 0 with a superset. + const noMergeBase = deriveBranchPaths({ ref: 'feature', run: gitScript([ + ['rev-parse --verify --quiet origin/main', ok40(A)], + ['rev-parse --verify --quiet feature', ok40(B)], + ['merge-base', { ok: false, out: '', error: '' }], + ]) }); + assert('⭐ an-uncomputable-merge-base-REFUSES-it-never-falls-back-to-two-dot', + noMergeBase.ok === false && /computed nothing/.test(noMergeBase.reason) && noMergeBase.paths === undefined && + /deepen|unshallow/.test(noMergeBase.remedy), JSON.stringify(noMergeBase)); + const refusalWords = renderDerivationRefusal({ mode: '--branch feature', reason: noMergeBase.reason, remedy: noMergeBase.remedy }); + assert('and-the-refusal-tells-the-reader-why-a-fallback-would-be-worse-than-no-answer', + refusalWords.includes('cannot derive') && refusalWords.includes('two-dot') && refusalWords.includes('#17003') && + refusalWords.includes('worse than none') && !refusalWords.includes('NOT governed'), refusalWords); + const derived = deriveBranchPaths({ ref: 'feature', run: gitScript([ + ['rev-parse --verify --quiet origin/main', ok40(A)], + ['rev-parse --verify --quiet feature', ok40(B)], + ['merge-base', ok40(M)], + ['diff --name-only --no-renames', { ok: true, out: 'src/a.ts\nsrc/b.ts\n', error: '' }], + ]) }); + assert('a-derivable-branch-answers-the-three-dot-list-from-the-merge-base', + derived.ok === true && derived.paths.join() === 'src/a.ts,src/b.ts' && derived.derivation.mergeBase === M, JSON.stringify(derived)); + assert('⛔ the-diff-is-taken-with---no-renames-so-a-rename-out-of-a-governed-path-still-hits', + derived.derivation.command.includes('--no-renames'), derived.derivation.command); + const branchLine = renderDerivation(derived.derivation); + assert('the-branch-reading-names-its-command-its-base-and-its-merge-base', + branchLine.includes('three-dot') && branchLine.includes('--no-renames') && branchLine.includes(M.slice(0, 10)) && + branchLine.includes('origin/main'), branchLine); + assert('and-an-unnamed-derivation-is-never-rendered-as-a-reading-to-act-on', + /do not act on this reading/.test(renderDerivation(null))); + + // The caller-derived note `--test` prints: it must name the right form, the + // wrong one, and the two ways out. + assert('the---test-note-names-three-dot-forbids-two-dot-and-points-at-both-derivations', + CALLER_DERIVED_NOTE.includes('origin/main...HEAD') && CALLER_DERIVED_NOTE.includes('never two-dot') && + CALLER_DERIVED_NOTE.includes('--pr') && CALLER_DERIVED_NOTE.includes('--branch'), CALLER_DERIVED_NOTE); + + // `--pr`'s page walk, against an injected fetch. The shape is PR #17076's, + // measured: 100 + 100 + 60 = 260 over three pages. + { + const respond = (body, link) => ({ + ok: true, + status: 200, + headers: { get: (k) => (String(k).toLowerCase() === 'link' ? link : null) }, + json: async () => body, + }); + const denied = { ok: false, status: 403, headers: { get: () => null }, json: async () => ({}) }; + const rows = (from, n) => Array.from({ length: n }, (_, i) => ({ filename: `packages/p/f${from + i}.ts`, status: 'modified' })); + const filesUrl = (page) => ``; + const pageFor = (url) => (/page=3/.test(url) ? respond(rows(200, 60), `${filesUrl(2)}; rel="prev"`) + : /page=2/.test(url) ? respond(rows(100, 100), `${filesUrl(3)}; rel="next"`) + : respond(rows(0, 100), `${filesUrl(2)}; rel="next"`)); + const one = [{ id: 'fixture', name: 'fixture channel', headers: {} }]; + const walk = await fetchPullFiles({ + apiUrl: 'https://api', slug: 'o/r', pull: 1, channels: one, + fetchImpl: async (url) => (/\/files/.test(url) ? pageFor(url) : respond({ changed_files: 260 }, null)), + }); + assert('⭐ the-walk-follows-Link-to-the-end-and-collects-every-page', + walk.ok === true && walk.paths.length === 260 && walk.derivation.pages === 3 && walk.derivation.entries === 260, + JSON.stringify({ ok: walk.ok, n: walk.paths?.length, reason: walk.reason })); + assert('and-the-reading-says-which-endpoint-and-how-many-pages-it-cost', + renderDerivation(walk.derivation).includes('three-dot by construction') && + renderDerivation(walk.derivation).includes('/repos/o/r/pulls/1/files') && + renderDerivation(walk.derivation).includes('3 page(s)'), renderDerivation(walk.derivation)); + const short = await fetchPullFiles({ + apiUrl: 'https://api', slug: 'o/r', pull: 1, channels: one, + fetchImpl: async (url) => (/\/files/.test(url) ? respond(rows(0, 100), null) : respond({ changed_files: 260 }, null)), + }); + assert('⭐ a-walk-the-PRs-own-count-contradicts-REFUSES-rather-than-answering-on-a-subset', + short.ok === false && /but the PR reports 260/.test(short.reason) && short.paths === undefined, JSON.stringify(short)); + // The channel is chosen once and kept: a fall-through mid-walk would splice + // two readings, taken at two instants, into one list and call it the PR's. + const two = [{ id: 'first', name: 'first channel', headers: {} }, { id: 'second', name: 'second channel', headers: {} }]; + let seen = 0; + const pickSecond = await fetchPullFiles({ + apiUrl: 'https://api', slug: 'o/r', pull: 1, channels: two, + fetchImpl: async (url, init) => { + if (!/\/files/.test(url)) { seen += 1; return seen === 1 ? denied : respond({ changed_files: 1 }, null); } + return respond([{ filename: 'a.ts' }], null); + }, + }); + assert('a-denied-channel-falls-through-to-the-next-one-on-the-first-read', + pickSecond.ok === true && pickSecond.derivation.channel === 'second', JSON.stringify(pickSecond)); + const dropMidWalk = await fetchPullFiles({ + apiUrl: 'https://api', slug: 'o/r', pull: 1, channels: two, + fetchImpl: async (url) => { + if (!/\/files/.test(url)) return respond({ changed_files: 200 }, null); + if (/page=2/.test(url)) return denied; // the SECOND page, on the channel that served the first + return respond(rows(0, 100), `${filesUrl(2)}; rel="next"`); + }, + }); + assert('⛔ but-a-page-that-fails-mid-walk-refuses-it-never-switches-channel-and-splices', + dropMidWalk.ok === false && /did not read/.test(dropMidWalk.reason), JSON.stringify(dropMidWalk)); + const renamedPr = await fetchPullFiles({ + apiUrl: 'https://api', slug: 'o/r', pull: 1, channels: one, + fetchImpl: async (url) => (/\/files/.test(url) ? respond(renamedOut, null) : respond({ changed_files: 1 }, null)), + }); + assert('and-a-renamed-governed-file-reaches-the-predicate-as-BOTH-of-its-paths', + renamedPr.ok === true && testVerdict(renamedPr.paths).governed === true && + renderDerivation(renamedPr.derivation).includes('AGENTS.md → docs/AGENTS.md'), JSON.stringify(renamedPr.paths)); + } + + // ── the card's own shape, on a REAL repo, end to end ───────────────────── + // + // ⭐ Every pure case above stays green if `main()` simply stops CONSULTING + // these derivations — the precedent this file already sets for its #13307 + // wiring. So the reproduction is RUN: a branch behind a `main` that has since + // touched a governed path, two-dot against three-dot, through the real CLI. + const derFx = mkdtempSync(join(tmpdir(), 'governed-merges-derive-')); + try { + const g2 = (cwd, ...rest) => + execFileSync('git', ['-c', 'user.email=t@t.invalid', '-c', 'user.name=t', '-c', 'init.defaultBranch=main', '-c', 'commit.gpgsign=false', ...rest], + { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + const repo = join(derFx, 'repo'); + g2(derFx, 'init', '-q', repo); + const put = (rel, text) => { + mkdirSync(dirname(join(repo, rel)), { recursive: true }); + writeFileSync(join(repo, rel), text, 'utf8'); + }; + put('README.md', 'seed\n'); + put('AGENTS.md', 'the governed root instruction file\n'); + g2(repo, 'add', '-A'); + g2(repo, 'commit', '-qm', 'chore: seed'); + const forkPoint = g2(repo, 'rev-parse', 'HEAD').trim(); + // `main` moves on and touches a GOVERNED path this branch never will — + // exactly what `gen:skill-refs` does to `skills/**` on ordinary landings. + put('.claude/hooks/guard.sh', '# main only\n'); + g2(repo, 'add', '-A'); + g2(repo, 'commit', '-qm', 'chore: main touches the governed tree'); + g2(repo, 'update-ref', 'refs/remotes/origin/main', g2(repo, 'rev-parse', 'main').trim()); + // The branch forks BEFORE that and changes one ordinary file. + g2(repo, 'checkout', '-q', '-b', 'feature', forkPoint); + put('src/a.ts', 'export const a = 1;\n'); + g2(repo, 'add', '-A'); + g2(repo, 'commit', '-qm', 'feat: an ordinary change'); + // A second branch, also behind main, that renames the governed root file. + g2(repo, 'checkout', '-q', '-b', 'rename-out', forkPoint); + g2(repo, 'mv', 'AGENTS.md', 'docs-AGENTS.md'); + g2(repo, 'commit', '-qm', 'chore: move the root instruction file'); + // And one with no shared history at all — the merge-base refusal, for real. + g2(repo, 'checkout', '-q', '--orphan', 'lonely'); + g2(repo, 'rm', '-rq', '--cached', '.'); + put('unrelated.txt', 'no shared history\n'); + g2(repo, 'add', '-A'); + g2(repo, 'commit', '-qm', 'chore: unrelated history'); + g2(repo, 'checkout', '-q', 'main'); + + const run = (...argv) => { + const r = spawnSync(process.execPath, [scriptPath, ...argv], { encoding: 'utf8', env: { ...process.env, [PROXY_REARM_GUARD]: '1' } }); + return { status: r.status, out: r.stdout ?? '', err: r.stderr ?? '' }; + }; + const twoDot = g2(repo, 'diff', '--name-only', 'origin/main', 'feature').trim().split('\n').filter(Boolean); + const threeDot = deriveBranchPaths({ ref: 'feature', run: (argv) => tryGit(repo, argv) }); + assert('⭐ the-cards-reproduction-two-dot-really-does-carry-mains-own-governed-path', + twoDot.includes('.claude/hooks/guard.sh') && testVerdict(twoDot).governed === true, JSON.stringify(twoDot)); + assert('⭐ and-the-three-dot-derivation-of-the-same-branch-is-NOT-governed', + threeDot.ok === true && threeDot.paths.join() === 'src/a.ts' && testVerdict(threeDot.paths).governed === false, + JSON.stringify(threeDot.paths ?? threeDot)); + const branchRun = run('--branch', 'feature', '--root', repo); + assert('⭐ and-the-CLI-itself-answers-NOT-governed-and-says-how-it-derived-the-list', + branchRun.status === EXIT_TEST_NOT_GOVERNED && branchRun.out.includes('derived from') && branchRun.out.includes('NOT governed'), + `status=${branchRun.status} out=${branchRun.out.slice(0, 400)}`); + const twoDotRun = run('--test', ...twoDot); + assert('while-the-two-dot-list-through---test-still-answers-GOVERNED-the-defect-is-the-INPUT', + twoDotRun.status === EXIT_TEST_GOVERNED, `status=${twoDotRun.status} out=${twoDotRun.out.slice(0, 300)}`); + // Zone (e)'s identity: one register, one answer, however the list was got. + const sameListRun = run('--test', ...threeDot.paths); + assert('⭐ the-verdict-is-BYTE-IDENTICAL-through---branch-and-through---test-on-the-same-list', + branchRun.out.endsWith(sameListRun.out) && sameListRun.out !== '', JSON.stringify({ b: branchRun.out.slice(-120), t: sameListRun.out.slice(-120) })); + assert('and---test-keeps-its-stdout-to-itself-the-three-dot-note-travels-on-stderr', + !sameListRun.out.includes('#17003') && sameListRun.err.includes('never two-dot'), sameListRun.out); + // ⭐ The rename, end to end. Default rename detection prints only the NEW + // path, so the same branch reads as ungoverned through the obvious command. + const renameDefault = g2(repo, 'diff', '--name-only', `${forkPoint}`, 'rename-out').trim().split('\n').filter(Boolean); + assert('⭐ gits-own-rename-detection-hides-the-old-path-so-the-obvious-command-under-reports', + renameDefault.join() === 'docs-AGENTS.md' && testVerdict(renameDefault).governed === false, JSON.stringify(renameDefault)); + const renameRun = run('--branch', 'rename-out', '--root', repo); + assert('⭐ while---branch-reads-both-paths-and-answers-GOVERNED', + renameRun.status === EXIT_TEST_GOVERNED && renameRun.out.includes('AGENTS.md'), + `status=${renameRun.status} out=${renameRun.out.slice(0, 400)}`); + // ⭐ The refusal, end to end — and the superset it refuses to become. + const collapsed = g2(repo, 'diff', '--name-only', 'lonely').trim().split('\n').filter(Boolean); + const lonelyRun = run('--branch', 'lonely', '--root', repo); + assert('⭐ an-uncomputable-merge-base-exits-1-with-a-refusal-and-NO-verdict-at-all', + lonelyRun.status === EXIT_CANNOT_SWEEP && lonelyRun.err.includes('cannot derive') && + !lonelyRun.out.includes('governed-surface predicate'), + `status=${lonelyRun.status} out=${lonelyRun.out.slice(0, 300)} err=${lonelyRun.err.slice(0, 300)}`); + assert('⭐ and-the-collapsed-shell-recipe-it-refuses-to-imitate-really-is-a-superset', + collapsed.length > 0 && testVerdict(collapsed).governed === true, JSON.stringify(collapsed)); + // Silence is not a verdict, in this mode either. + const emptyRun = run('--branch', 'main', '--root', repo); + assert('a-branch-that-changes-nothing-is-a-failure-never-a-not-governed-answer', + emptyRun.status === EXIT_CANNOT_SWEEP && emptyRun.err.includes('ZERO paths'), `status=${emptyRun.status} err=${emptyRun.err.slice(0, 200)}`); + const bothRun = run('--test', 'AGENTS.md', '--branch', 'feature', '--root', repo); + assert('two-mode-flags-name-two-lists-and-one-verdict-cannot-be-about-both', + bothRun.status === EXIT_CANNOT_SWEEP && bothRun.err.includes('DIFFERENT file list'), `status=${bothRun.status} err=${bothRun.err.slice(0, 200)}`); + const noRefRun = run('--branch', '--root', repo); + assert('and---branch-with-no-ref-is-bad-args-never-a-sweep', + noRefRun.status === EXIT_CANNOT_SWEEP && noRefRun.err.includes('--branch wants a git ref'), noRefRun.err.slice(0, 200)); + } finally { + rmSync(derFx, { recursive: true, force: true }); + } + // ── The floor: every declared battery RAN, and ran its cases (#13489) ──── // // Evaluated after every battery has had its chance and BEFORE the verdict, so @@ -4562,7 +4848,7 @@ async function selfTest() { for (const failure of failures) console.error(` • ${failure}`); process.exit(1); } - console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the five-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes — and the live battery's own PREREQUISITE, asked before a single case runs: an uninstalled checkout refuses with the repo-wide NOT-MEASURED code end to end instead of reporting a shrunken battery, while the floor still names the battery, by itself, for a case that genuinely stopped registering) — and the #15406 replay of PR #15284: the sweep still CLASSIFIES a certified regeneration as a governed merge and still lists it, its row now names the register row it does not recompute and where certification is recorded, and the --test head no longer reports a post-lift zero as if nothing had hit the register.\n ${liveNote}`); + console.log(`✓ check-governed-merges --self-test: ${checked} assertions (the unified governed predicate + near misses, subject→PR spellings, window parsing, the #12633 landing window — the QS-7 regression pin in both directions, the topological close beyond the budget, the unproven-boundary EDGE, the listed-or-INCOMPLETE invariant over every fixture, the escalating floors, per-repo --since-ref resolution and its named fallback, and the window words — the replay fixtures, the five-repo resolution incl. absent/wrong-origin/relocated checkouts, the attribution channel chain + its proxy-transport re-arm plan and its one named fallback line, the three-way attribution column (resolved · every-channel-failed · NOT LOOKED UP, and the note pointer that belongs to the middle one alone), the --test pre-arm predicate, the generated-artifact provenance exception — the register's invariants incl. the RETIRED #9866 row staying retired (no row lifts anything under .claude/**, and the audit workflow is plainly governed again), a row with no recompute failing closed, lift/reject/absent-provenance semantics, the untouched mixed-diff rule, named-rows-not-a-class, the #11084 generator co-edit fence in both directions incl. a row with no instrument tree, and its render words — the #11705 generator-owned rows inside skills/** (a genuine generated file passes, the same path hand-edited does not, a path no generator declares is hand-authored content, per-row fences, and the enumeration read from the real generator), the exit table, the report wording pins, and the #13307 remote-reachability leg — the pure freshness verdicts in every branch (unreachable · a remote naming no commit · an unreadable local tip · a mirror behind its remote · the two-unreadable-shas degenerate case that must never read as a match), the report words in both directions (an unreachable repo never renders the tick, a reachable one still says a MEASURED zero, and a row with no remote reading never claims one), and the REAL prober on local bare-repo fixtures over the file transport — a live remote, a deleted one, the --exit-code branch, and a mirror the remote moved past — the #13423 identity leg (an origin no slug parses from refuses, pure and end-to-end, with audited reachable only through a parsed matching slug), the #13424 per-repo window resolution (a sibling-only pin resolves in its own repo, the self-only control still errors, and the end-to-end sibling-pin sweep reports instead of exiting 1), the #13307 sweep-code provenance line in all three branches, and the #13836 attribution set — every refusal carries its precondition category on the row, in the footer, and in --json; the shallow-clone path in both directions; and the run-1-vs-run-2 flip reproduced on real fixtures with zero local writes — and the live battery's own PREREQUISITE, asked before a single case runs: an uninstalled checkout refuses with the repo-wide NOT-MEASURED code end to end instead of reporting a shrunken battery, while the floor still names the battery, by itself, for a case that genuinely stopped registering) — and the #15406 replay of PR #15284: the sweep still CLASSIFIES a certified regeneration as a governed merge and still lists it, its row now names the register row it does not recompute and where certification is recorded, and the --test head no longer reports a post-lift zero as if nothing had hit the register — and the #17003 derivation set: the Link walk that ends on rel=next rather than on a short page, a rename reaching the predicate as BOTH of its paths, a walk the PR's own count contradicts refusing rather than answering on a subset, a channel chosen once and never spliced mid-walk, every --branch leg on an injected git incl. the uncomputable merge base that REFUSES instead of falling back to two-dot, and the card's own reproduction run end to end on a real repo — a branch behind a main that has since touched a governed path answers GOVERNED two-dot and NOT governed three-dot, a rename out of a governed path is a hit only because the diff is taken --no-renames, the merge-base refusal prints no verdict at all, and the verdict is byte-identical through --branch and through --test on the same list.\n ${liveNote}`); return SELF_TEST_VERDICT; }