diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 95520f3676..ec8fa23cce 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -1516,25 +1516,35 @@ describe('build-schemas.ts — the drift notice names the direction it measured // // Truncation moves TWO things here, and the second was a surprise worth // writing down: `merge-base HEAD origin/main` itself fails once the walk is - // cut, so `resolveSurfaceBase` falls back to origin/main's TIP as the - // baseline (it says so — "no merge base is walkable here, so this run - // anchors on the origin/main TIP …", the line #6359 reworded to name what - // that anchor then MISJUDGES). - // The pair being compared is therefore anchor-at-`tip` vs baseline-at- - // `mainTip`, not the fork point at all. And the ancestry between them is - // exactly what a grafted history cannot answer: `mainTip` is its own shallow - // root, so walking down from it to reach `tip` is the walk that was cut, and - // the reverse is a plain negative. Neither probe yields a usable answer. + // cut, so `resolveSurfaceBase` has to anchor the baseline somewhere else. + // The pair being compared is therefore the committed anchor against THAT + // baseline, not against the fork point — and the ancestry between them is + // exactly what a grafted history cannot answer, in either direction. // - // The old line printed "trails the baseline at by 1 key(s)" here, + // The old line printed "trails the baseline at by 1 key(s)" here, // which happens to be TRUE of the untruncated history — and that is the // point: it was never measured, it was assumed, and one fixture over the // same assumption printed the exact opposite of the truth. Declining is the // same disposition #5370 already took for the write. + // + // #6452 re-based this fixture without changing its subject. The baseline a + // shallow run resolves is no longer origin/main's TIP (that is what made + // main's additions read as this branch's deletions), so the fixture now + // says on MAIN which upstream rev the anchor names — `older` — instead of + // inheriting whichever anchor an earlier case left behind. And BOTH fetched + // revs are grafted, which is what a checkout that fetched two commits at + // `--depth=1` actually looks like: with `tip` a shallow root too, neither + // ancestry probe can answer and the notice must still decline. + seedSurfaceBase(older, (k) => k.filter((x) => x !== AHEAD_KEY && x !== LANDED_KEY)); + git('add', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: main records its anchor at the older baseline'); + const mainHead = git('rev-parse', 'HEAD'); + git('update-ref', 'refs/remotes/origin/main', mainHead); + git('checkout', '-q', '-B', 'issue-5847-shallow', older); seedSurface((s) => s); const anchorAtTip = commitAnchor(tip, (k) => k.filter((x) => x !== LANDED_KEY)); - fs.writeFileSync(shallowFile(), `${mainTip}\n`); + fs.writeFileSync(shallowFile(), `${mainHead}\n${tip}\n`); expect(git('rev-parse', '--is-shallow-repository')).toBe('true'); const { status, output } = run([]); @@ -1544,8 +1554,8 @@ describe('build-schemas.ts — the drift notice names the direction it measured expect(git('status', '--porcelain', '-uno')).toBe(''); expect(output).toContain('differs from the baseline this build resolved'); expect(output).toContain( - `${tip.slice(0, 12)}, that baseline is at ${mainTip.slice(0, 12)}, and they differ by ` + - `1 key(s) only that baseline has`, + `${tip.slice(0, 12)}, that baseline is at ${older.slice(0, 12)}, and they differ by ` + + `1 key(s) only the anchor has`, ); expect(output).toContain( 'shallow checkout — a "not an ancestor" answer is not usable about a truncated history', @@ -1612,6 +1622,241 @@ describe('build-schemas.ts — the drift notice names the direction it measured ); }); +// ───────────────────────────────────────────────────────────────────────────── +// #6452 — a truncated history moves the ANCHOR, never the verdict. +// +// `merge-base HEAD origin/main` cannot answer in a shallow checkout, and the old +// fallback anchored on origin/main's TIP. Under a tip anchor "main added a key +// after this branch forked" and "this branch deleted a key" are the SAME fact, +// so the gate reported the first as the second: #6359 measured PR #6356 — which +// touched no packages/spec file at all — being told it had deleted +// `ui/BulkActionDef:requiredPermissions`, a key main had just added. Nothing +// guarded that path (the calling block carries no `if (CHECK)`) and its verdict +// is `process.exit(1)`, so it is every `gen:schema` in a shallow job, not one +// gate in one mode. +// +// The two obvious dispositions were both refused before this one was chosen: +// reporting "unverified" instead of adjudicating is the #4650 bypass in every +// shallow job at once (`resolveSurfaceBase`'s own doc comment says so), and +// erroring on the CI configuration paints that whole set of jobs red. So the +// anchor moves and the verdict does not: the gate still runs, and a key that +// existed at the anchored rev and is gone now is still caught. +// +// What these cases pin, and why each one can go red: +// +// 1. the false red is gone — same tree, same truncation, main's addition is +// no longer this branch's deletion; +// 2. the gate did NOT weaken — a real deletion is still red, and it names the +// deleted key rather than main's addition; +// 3. the baseline's keys come from GIT at that commit, never from the anchor +// FILE. This is the acceptance criterion that cannot be assumed: if the +// resolution took the file's own `keys`, `verifyCommittedSurfaceBase` would +// hit its `rev === resolved.rev` fast path and compare the anchor against +// itself, so a line shed from it would pass. The fixture sheds one; +// 4. an anchor rev nothing upstream vouches for is not used — a PR can point +// `baseRev` at one of its OWN commits (a `--depth=1` fetch resolves any sha +// the remote advertises), and a truncated history cannot refute it by +// walking, so the rev is accepted only when origin/main's own copy of the +// anchor names it (or reachability is demonstrated outright); +// 5. with no upstream anchor at all the run keeps the tip and SAYS so, naming +// `fetch-depth: 0`. That residual false red is the honest degradation, and +// it is pinned so it stays loud rather than becoming a silent skip. +describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, it does not accuse (#6452)', () => { + /** Only in the baseline at origin/main's TIP: what main added after this branch forked. */ + const MAIN_ADDED_KEY = 'data/Object:zzAddedOnMainAfterTheFork6452'; + /** In the baseline at the ANCHORED rev too, so its absence is a real deletion. */ + const BRANCH_DELETED_KEY = 'data/Object:zzDeletedByThisBranch6452'; + /** A live key this build really emits — shed from the anchor FILE by case 3. */ + const SHED_KEY = 'data/Object:label'; + + const shallowFile = (): string => path.join(sandbox, '.git', 'shallow'); + + /** `git()` throws on a non-zero exit, which is what the fixture guards expect. */ + const mergeBaseFails = (rev: string): boolean => + spawnSync('git', ['merge-base', 'HEAD', rev], { cwd: sandbox }).status !== 0; + + beforeAll(() => { + expect(pristineSurface, `${SHED_KEY} is no longer in the baseline — pick another live key`).toContain( + SHED_KEY, + ); + }); + + /** + * The upstream ladder every case forks from: a fork point, main's own anchor + * committed ON MAIN at it, then a main that moves ahead and ADDS a key. + * + * Committing the anchor on main is what makes these fixtures model CI rather + * than a laboratory: origin/main's copy of that file is the only statement + * about which rev the anchor names that a PR cannot rewrite, and it is exactly + * what a `--depth=1` fetch of main still carries. + */ + function seedUpstream(baseKeys: (keys: string[]) => string[]): { + forkBase: string; + anchored: string; + mainTip: string; + } { + seedManifest((s) => s); + const forkBase = seedBase(baseKeys); + seedSurfaceBase(forkBase, baseKeys); + git('add', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: main anchors at the fork point'); + const anchored = git('rev-parse', 'HEAD'); + git('update-ref', 'refs/remotes/origin/main', anchored); + const mainTip = seedBase((s) => [...baseKeys(s), MAIN_ADDED_KEY].sort()); + return { forkBase, anchored, mainTip }; + } + + /** Fork at an upstream commit and truncate history the way CI's checkout does. */ + function forkBranch(name: string, at: string, mainTip: string): void { + git('checkout', '-q', '-B', name, at); + // The worktree carries what this build emits, so every case below is judged + // on its baseline rather than on artifact staleness. + seedSurface((s) => s); + fs.writeFileSync(shallowFile(), `${mainTip}\n`); + expect(git('rev-parse', '--is-shallow-repository')).toBe('true'); + expect(mergeBaseFails(mainTip), 'the fixture is not truncated — merge-base still answers').toBe(true); + } + + afterEach(() => { + fs.rmSync(shallowFile(), { force: true }); + git('checkout', '-q', '-f', 'main'); + // Hand `main` back current and CLEAN, so the describes after this one start + // from a tree with no fixture of ours in it — the surface first, then an + // anchor that names the commit just made, so what is left behind is authentic + // by construction rather than by luck (one case here removes main's anchor + // outright, and the next describe reads whatever this leaves). + seedSurface((s) => s); + git('add', AUTHORABLE_SURFACE_DIR_NAME); + git('commit', '-q', '--allow-empty', '-m', 'fixture: restore the pristine surface on main'); + seedSurfaceBase(git('rev-parse', 'HEAD'), (k) => k); + git('add', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: restore a current anchor on main'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + }); + + it( + 'no false red: a key main added after the fork is not reported as this branch deleting it', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const { forkBase, anchored, mainTip } = seedUpstream((s) => s); + forkBranch('issue-6452-no-false-red', anchored, mainTip); + + const { status, output } = run(['--check']); + + // The anchor moved, and the line says which rev and on whose authority. + expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`); + expect(output).toContain("origin/main's own authorable-surface.base.json names the same commit"); + expect(output).not.toContain('no merge base is walkable here'); + // The defect itself: under the tip anchor this run exited 1 naming + // MAIN_ADDED_KEY as an unproven deletion. + expect(output).not.toContain('deleted without proof'); + expect(output).not.toContain(MAIN_ADDED_KEY); + expect(status).toBe(0); + }, + ); + + it( + 'the gate does not weaken: a genuine deletion is still red under the same truncation', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const { forkBase, anchored, mainTip } = seedUpstream((s) => [...s, BRANCH_DELETED_KEY].sort()); + forkBranch('issue-6452-real-deletion', anchored, mainTip); + + const { status, output } = run(['--check']); + + expect(status).toBe(1); + expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`); + expect(output).toContain('1 authorable baseline line(s) were deleted without proof (#4650)'); + expect(output).toContain(BRANCH_DELETED_KEY); + // Exactly one, and the right one: main's addition is not in the verdict. + expect(output).not.toContain(MAIN_ADDED_KEY); + }, + ); + + it( + 'the baseline keys come from git at that commit, never from the anchor file (no self-verification)', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const { forkBase, anchored, mainTip } = seedUpstream((s) => s); + forkBranch('issue-6452-shed-anchor-key', anchored, mainTip); + // The anchor sheds a line the commit it NAMES really carries — the #4650 + // attack moved one file over, and the shape the shortcut would bless. + seedSurfaceBase(forkBase, (k) => k.filter((x) => x !== SHED_KEY)); + + const { status, output } = run(['--check']); + + // THE pin. Resolve the baseline from the anchor file's own `keys` and this + // comparison becomes file-against-file: it passes, the run exits 0, and the + // shed line is gone from the baseline for good. Reading the keys out of git + // at `baseRev` is the only thing that makes it red. + expect(status).toBe(1); + expect(output).toContain('is not the baseline it claims to be (#4650, #5235)'); + expect(output).toContain(`- ${SHED_KEY} (at ${forkBase.slice(0, 12)}, absent here)`); + }, + ); + + it( + 'an anchor rev nothing upstream vouches for is not used — the rev origin/main records is', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const { forkBase, anchored, mainTip } = seedUpstream((s) => [...s, BRANCH_DELETED_KEY].sort()); + git('checkout', '-q', '-B', 'issue-6452-unvouched', anchored); + // The branch deletes the key, commits it, and then points `baseRev` at its + // OWN commit — authentic against itself (its keys ARE that commit's + // surface), upstream against nothing. A truncated history cannot refute it + // by walking, which is why the rev has to be vouched for rather than merely + // checked. + seedSurface((s) => s); + git('add', AUTHORABLE_SURFACE_DIR_NAME); + git('commit', '-q', '-m', 'fixture: the branch deletes a baseline key'); + const branchOwn = git('rev-parse', 'HEAD'); + seedSurfaceBase(branchOwn, (k) => k); + git('add', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: the branch anchors on its own commit'); + fs.writeFileSync(shallowFile(), `${mainTip}\n`); + expect(mergeBaseFails(mainTip)).toBe(true); + + const { status, output } = run(['--check']); + + expect(output).toContain(`names ${branchOwn.slice(0, 12)}, which nothing here can`); + expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`); + // …and the deletion the forged anchor was hiding is still adjudicated. + expect(status).toBe(1); + expect(output).toContain('deleted without proof (#4650)'); + expect(output).toContain(BRANCH_DELETED_KEY); + }, + ); + + it( + 'with no upstream anchor at all it keeps the tip and says so, naming fetch-depth: 0', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The honest degradation, pinned so it stays LOUD. A main whose tree carries + // no anchor cannot vouch for anything, so this run has only the tip — and + // the tip anchor is the defect. It reports the residual false red instead of + // waiving the check, because a diagnosable false red beats a silent bypass. + seedManifest((s) => s); + const forkBase = seedBase((s) => s); + seedSurfaceBase(forkBase, (k) => k); + git('add', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: anchor at the fork point'); + const anchored = git('rev-parse', 'HEAD'); + git('rm', '-q', 'authorable-surface.base.json'); + const mainTip = seedBase((s) => [...s, MAIN_ADDED_KEY].sort()); + forkBranch('issue-6452-no-upstream-anchor', anchored, mainTip); + + const { status, output } = run(['--check']); + + expect(output).toContain('no merge base is walkable here, and no upstream anchor was usable'); + expect(output).toContain(`anchors on the origin/main TIP ${mainTip.slice(0, 12)} instead`); + expect(output).toContain('fetch-depth: 0'); + expect(status).toBe(1); + expect(output).toContain('deleted without proof'); + expect(output).toContain(MAIN_ADDED_KEY); + }, + ); +}); + // ───────────────────────────────────────────────────────────────────────────── // #5371 — the output clean is scoped to THIS generator's artifacts. // diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 2cb47576aa..184aa4c02f 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -1170,6 +1170,28 @@ const gitInPackage: GitRun = (...args: string[]) => timeout: 60_000, }); +/** + * Make one commit READABLE in this checkout, fetching it when the tree does not + * hold it yet (#5235, factored out for #6452's second caller). + * + * `--depth=1` is the whole point rather than a compromise: the commit is wanted + * for its TREE — the shards under it — never for a walk, and every consumer here + * already treats a truncated walk as "no answer" rather than as a verdict. A + * deeper fetch would be a bounded workaround for the truncation instead of a read + * of the one commit the gate names, which #6452 rejected in as many words ("把 + * 「永远走不通」换成「偶尔走不通」,更难诊断"). + * + * False means neither the checkout nor the remote can produce it: an offline + * container, or a rev nothing upstream advertises. Callers decide what that + * costs them; none of them may treat it as a pass. + */ +function ensureCommitPresent(git: GitRun, rev: string): boolean { + if (git('cat-file', '-e', `${rev}^{commit}`).status === 0) return true; + // Shallow checkout (CI's typecheck job): ask the remote for that one commit. + git('fetch', '--quiet', '--depth=1', 'origin', rev); + return git('cat-file', '-e', `${rev}^{commit}`).status === 0; +} + /** * The in-tree anchor must be an authentic copy of an UPSTREAM commit's baseline, * and this is the environment that can prove it (#5235). @@ -1204,20 +1226,26 @@ function verifyCommittedSurfaceBase( // Fast path, and the common one right after a refresh: the anchor names the // very rev this run resolved out of git, so the baseline to compare against is - // already in hand — no object lookup, and no ancestry question either (that - // rev IS origin/main's merge base or tip). + // already in hand — no object lookup, and no ancestry question either. + // + // What makes it sound is a property of `resolved`, not of the equality: those + // keys were READ OUT OF GIT at that rev, never out of this file. Every producer + // of a `SurfaceBaseResolution.gitAnchor` goes through `readSurfaceKeysAtRev`, + // including the shallow re-anchor added by #6452 — which is the one caller that + // can make `rev === resolved.rev` true by CONSTRUCTION rather than by + // coincidence, and would therefore be exactly where a file-validating-file + // comparison could hide. Keeping the keys git-sourced is what stops it; the pin + // is behavioural (a key shed from this file is still caught under `.git/shallow` + // — see build-schemas-check-mode.test.ts) rather than a comment asserting it. + // + // The ancestry half is not skipped by that caller either: it establishes the + // rev is upstream BEFORE handing it over — see `resolveBaselineWithoutMergeBase`. if (rev === resolved.rev) { compareAnchorKeys(resolved.keys, committed, short, fix); return; } - let present = git('cat-file', '-e', `${rev}^{commit}`).status === 0; - if (!present) { - // Shallow checkout (CI's typecheck job): ask the remote for that one commit. - git('fetch', '--quiet', '--depth=1', 'origin', rev); - present = git('cat-file', '-e', `${rev}^{commit}`).status === 0; - } - if (!present) { + if (!ensureCommitPresent(git, rev)) { console.log( `ℹ️ ${SURFACE_BASE_FILE_NAME}: commit ${short} is not in this checkout and could not be\n` + ` fetched, so its authenticity is unverifiable here. This run anchored on the merge base\n` + @@ -1230,13 +1258,21 @@ function verifyCommittedSurfaceBase( // `merge-base --is-ancestor` answers "not an ancestor" for a commit that // demonstrably is one — the same truncation the merge-base fallback above // already accounts for, and it fails the whole build if trusted (caught on this - // change's own first CI run). Ask whether the answer can mean anything first. - if (git('rev-parse', '--is-shallow-repository').stdout.trim() === 'true') { + // change's own first CI run). Ask whether the answer can mean anything first — + // through `probeAncestry`, the ONE reading of those exit codes this file has + // (#5370/#5847). It used to be open-coded here, which also meant git DECLINING + // to answer (exit 128, the cloud#1116 trap) was read as a verdict of "not an + // ancestor" and failed the build; the shared reading tells the two apart. + const ancestry = probeAncestry(git, rev, tip); + if (ancestry.answer === 'unknown') { console.log( - `ℹ️ ${SURFACE_BASE_FILE_NAME}: shallow checkout — cannot walk history to confirm ${short} is\n` + - ` on origin/main, so only its recorded keys are verified here (#5235). A full clone checks both.`, + ancestry.reason === 'shallow' + ? `ℹ️ ${SURFACE_BASE_FILE_NAME}: shallow checkout — cannot walk history to confirm ${short} is\n` + + ` on origin/main, so only its recorded keys are verified here (#5235). A full clone checks both.` + : `ℹ️ ${SURFACE_BASE_FILE_NAME}: \`git merge-base --is-ancestor\` did not answer about ${short}\n` + + ` (exit ${ancestry.status}): ${ancestry.stderr} — so only its recorded keys are verified here (#5235).`, ); - } else if (git('merge-base', '--is-ancestor', rev, tip).status !== 0) { + } else if (ancestry.answer === 'no') { console.error( `\n❌ ${SURFACE_BASE_FILE_NAME} names a baseRev (${short}) that is NOT an ancestor of\n` + ` origin/main (#5235).\n\n` + @@ -1453,6 +1489,119 @@ function assertAnchorMovesForward(git: GitRun, committedRev: string, resolvedRev process.exit(1); } +/** + * The `baseRev` ORIGIN/MAIN itself records, read out of the anchor file as it is + * committed at the tip (#6452). + * + * This is the one statement about which upstream commit the anchor names that a + * PR cannot rewrite: the bytes live in main's tree, not in the tree under test. + * And it is readable in exactly the checkouts where ancestry is NOT — a + * `--depth=1` fetch of main carries that commit's whole tree, while its history + * is precisely what got cut. + * + * Null when main carries no anchor (a history predating #5235) or carries one + * this reader cannot make sense of. Deliberately quiet: nothing here is a verdict + * about the tree under test, only an input the caller may or may not get. + */ +function readUpstreamAnchorRev(git: GitRun, tip: string): string | null { + const show = git('show', `${tip}:./${SURFACE_BASE_FILE_NAME}`); + if (show.status !== 0) return null; + try { + const doc = JSON.parse(show.stdout) as Partial; + const rev = doc?.baseRev ?? ''; + return /^[0-9a-f]{40}$/.test(rev) ? rev : null; + } catch { + return null; + } +} + +/** + * The baseline anchor for a checkout where `merge-base HEAD origin/main` cannot + * answer — a shallow one, which is every CI job that does not ask for + * `fetch-depth: 0` and every agent container (#6452). + * + * ── Why the tip is the wrong answer there ─────────────────────────────────── + * Under a TIP anchor, "main added a key after this branch forked" and "this + * branch deleted a key" are the SAME fact, and the gate reports the first as the + * second: #6359 measured PR #6356, which touched no packages/spec file at all, + * being told it had deleted `ui/BulkActionDef:requiredPermissions` — a key main + * had just ADDED. The correlation is inverted, which is what makes it expensive: + * it fires on the PRs where "you deleted an authorable key" is most believable. + * + * ── Why not simply skip the check ─────────────────────────────────────────── + * Because that is the #4650 bypass with extra steps, in every shallow job at + * once. So the disposition is to move the ANCHOR, never the verdict: the gate + * still runs, still adjudicates, and a key that existed at the anchored rev and + * is gone now is still caught. What it stops seeing is keys main added AFTER the + * anchored rev — which is the false-positive set, not the deletion set. + * + * ── Which rev, and why it has to be earned ────────────────────────────────── + * The obvious candidate is the in-tree anchor's own `baseRev`: it is upstream, it + * is normally no NEWER than the branch's fork point, and the offline route + * already anchors there (#5235). But its authenticity has two parts + * (`verifyCommittedSurfaceBase`), and a shallow checkout can only prove the + * second: `merge-base --is-ancestor` reports "not an ancestor" about a commit + * that plainly is one, so part 1 SKIPS — the gate says so itself. Today that skip + * is free, because that same doc comment records why: "in that environment the + * merge-base anchor — not this file — is what the deletion check ran on anyway." + * Making the file load-bearing there is exactly what removes that sentence's + * protection, and a PR CAN point `baseRev` at one of its own commits (a + * `--depth=1` fetch resolves any sha the remote advertises, its own head + * included) whose shards already lack the key it is deleting. Both halves of the + * key check then pass, against a baseline the PR authored. + * + * So the rev is accepted only when something the PR does not control says it is + * upstream, in this order: + * + * 1. reachability DEMONSTRATED — `probeAncestry` answers "yes", which is proof + * in every checkout, truncated included (a cut walk can only lose + * reachability, never invent it). Rare in the shallow case by construction; + * it is what a non-shallow checkout with unrelated histories gets. + * 2. ORIGIN/MAIN NAMES THE SAME REV — main's own committed anchor points at it. + * The ordinary case: the anchor moves only under an explicit `--update-base` + * (#5358), so a branch and main agree on it unless a re-anchor landed in + * between. + * 3. otherwise, the rev MAIN names, never the one this tree names. Still + * upstream, still far older than the tip, and unforgeable — the residue is + * that it may be NEWER than the branch's fork point, which narrows the false + * positive window rather than closing it. Announced, so it is never mistaken + * for case 2. + * + * With none of those available the caller keeps today's tip anchor and says so: + * a loud false red beats a silent bypass, and that is the honest degradation for + * a checkout that can see origin/main's tip and nothing else. + */ +function resolveBaselineWithoutMergeBase( + git: GitRun, + tip: string, + committed: AuthorableSurfaceBase | null, +): { rev: string; why: string } | null { + if (!committed) return null; + const own = committed.baseRev; + const upstreamRev = readUpstreamAnchorRev(git, tip); + const ownPresent = ensureCommitPresent(git, own); + + if (ownPresent && probeAncestry(git, own, tip).answer === 'yes') { + return { rev: own, why: `${own.slice(0, 12)} is a demonstrated ancestor of origin/main` }; + } + if (ownPresent && upstreamRev === own) { + return { + rev: own, + why: `origin/main's own ${SURFACE_BASE_FILE_NAME} names the same commit, so it is upstream`, + }; + } + if (upstreamRev && upstreamRev !== own && ensureCommitPresent(git, upstreamRev)) { + return { + rev: upstreamRev, + why: + `this tree's ${SURFACE_BASE_FILE_NAME} names ${own.slice(0, 12)}, which nothing here can\n` + + ` show is upstream (truncated history), so this run anchors on ${upstreamRev.slice(0, 12)} —\n` + + ` the rev origin/main's own copy records`, + }; + } + return null; +} + /** * What `resolveSurfaceBase()` resolved: the baseline itself, plus — only when * the GIT path produced it — the anchor that path is allowed to write. @@ -1488,12 +1637,18 @@ type SurfaceBaseResolution = { * when no baseline existed there at all; failure to ANCHOR the base is fatal — * a deletion check that silently skips is the #4650 bypass with extra steps. * - * Two anchors, in strict preference order (#5235): + * Three anchors, in strict preference order (#5235, #6452): * * `merge-base` — origin/main is reachable (every dev checkout, every CI run). * Unchanged from #4650: the baseline is read out of git at the merge base, * and the in-tree anchor is additionally VERIFIED against it here, which is * what makes that file trustworthy in the environments that cannot check. + * `re-anchor` — origin/main resolves but its history is TRUNCATED, so + * `merge-base` has nothing to walk. The rev then comes from an upstream + * anchor and the keys are read out of git at it — see + * `resolveBaselineWithoutMergeBase` for which rev is eligible and why the + * tip is not. Nothing is skipped and nothing is waived; only the anchor + * moves (#6452). * `in-tree` — origin/main is not resolvable and no fetch can make it so. That * is not a developer who forgot to fetch; it is a build environment with no * route to GitHub: cloud's buildx image stages (framework is COPYed into the @@ -1530,34 +1685,49 @@ function resolveSurfaceBase(): SurfaceBaseResolution | null { const tip = tipProbe.stdout.trim(); // Merge base, so a branch behind origin/main is compared against what it // FORKED from (keys added on main since then are not "deleted" here). In a - // shallow clone there is no walkable ancestry — fall back to the tip, which - // on a PR's synthetic merge commit is the merge base anyway. + // shallow clone there is no walkable ancestry, and the old fallback used the + // TIP — which the parenthetical here used to justify as "on a PR's synthetic + // merge commit that is the merge base anyway". It is not: the merge ref is + // built when the PR is opened or updated and goes stale as main advances, + // while the `--depth=1` fetch above always brings back main's CURRENT tip. + // #6359 measured the two apart and the gate called main's addition this + // branch's deletion. Re-anchor instead of re-judging (#6452). const mergeBase = git('merge-base', 'HEAD', tip); - const rev = mergeBase.status === 0 ? mergeBase.stdout.trim() : tip; + let rev = mergeBase.status === 0 ? mergeBase.stdout.trim() : tip; if (mergeBase.status !== 0) { - // That "…is the merge base anyway" holds only while the merge ref is - // fresh. It is generated when the PR opens or updates and goes STALE as - // main advances, so on a branch that forked earlier the tip anchor - // carries keys the fork point never had — and this gate reads every one - // of them as a line THIS commit deleted. The direction is worth spelling - // out because the verdict it produces ("deleted without proof") reads - // like a severe spec violation and costs far more to diagnose than to - // fix: #6359 was one CI job missing `fetch-depth: 0`, and the PR it - // reddened (#6356) had not touched packages/spec at all. - // - // Diagnostic only — the verdict below is unchanged. Making this route - // stop MISJUDGING rather than merely announcing itself is a separate - // decision with a much wider blast radius: this block is top-level, so - // every `gen:schema` runs it, which means every shallow job that builds - // @objectstack/spec (ci.yml `build-core`, docker-publish, release, …) - // takes this path whenever that build is a cache miss. Tracked in #6452. - console.log( - ` (shallow history — no merge base is walkable here, so this run anchors on the\n` + - ` origin/main TIP ${tip.slice(0, 12)} instead. ⚠️ Under a tip anchor a key that main ADDED\n` + - ` after this branch forked is indistinguishable from a key this branch DELETED. If a\n` + - ` deletion is reported below for a file you did not touch, check that first — and if\n` + - ` this is CI, the job's checkout step needs \`fetch-depth: 0\` (#6359).)`, - ); + // `--update-base` is deliberately excluded. Its job is to resolve a NEW + // baseline out of git and write it down, so anchoring it on the anchor is + // circular: the run would report "nothing to re-anchor" instead of the + // #5370 refusal a truncated history owes it (`assertAnchorMovesForward` + // fails closed there, and that refusal is the correct answer). + const reanchored = UPDATE_BASE ? null : resolveBaselineWithoutMergeBase(git, tip, committed?.doc ?? null); + if (reanchored) { + rev = reanchored.rev; + console.log( + `ℹ️ shallow history — \`merge-base HEAD origin/main\` cannot answer here, so the\n` + + ` authorable-surface deletion check (#4650) anchors on ${rev.slice(0, 12)} rather than on\n` + + ` origin/main's tip ${tip.slice(0, 12)} (#6452): ${reanchored.why}.\n` + + ` Under a tip anchor "main added a key after this branch forked" and "this branch deleted\n` + + ` a key" are the same fact, and the gate reports the first as the second. The baseline's\n` + + ` keys are read from git at that commit, never from ${SURFACE_BASE_FILE_NAME} itself.`, + ); + } else { + // #6359's diagnostic, kept for the one arm it still describes. With no + // upstream anchor to move to, the tip is all this run has — so the + // direction it misjudges is exactly what the reader needs spelled out, + // because the verdict it produces ("deleted without proof") reads like a + // severe spec violation and costs far more to diagnose than to fix + // (#6359 was one CI job missing `fetch-depth: 0`, and the PR it reddened, + // #6356, had not touched packages/spec at all). + console.log( + ` (shallow history — no merge base is walkable here, and no upstream anchor was usable\n` + + ` either (#6452), so this run anchors on the origin/main TIP ${tip.slice(0, 12)} instead.\n` + + ` ⚠️ Under a tip anchor a key that main ADDED after this branch forked is\n` + + ` indistinguishable from a key this branch DELETED. If a deletion is reported below for\n` + + ` a file you did not touch, check that first — and if this is CI, the job's checkout\n` + + ` step needs \`fetch-depth: 0\` (#6359).)`, + ); + } } const baseline = readSurfaceKeysAtRev( git,