From fc8010cfad2e1b721281c988421671a5951e593e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:31:27 +0000 Subject: [PATCH 1/2] wip(spec): shallow builds anchor the schema ratchets on the commit's own recorded anchor, never on origin/main Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu Co-authored-by: Claude --- packages/spec/scripts/build-schemas.ts | 254 ++++++++++++------------- 1 file changed, 122 insertions(+), 132 deletions(-) diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index a266dcd7e29..525bcddb80a 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -2024,8 +2024,12 @@ function verifyCommittedSurfaceBase( // 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`. + // The ancestry half is NOT established by that caller, and cannot be: a + // truncated history cannot show the rev is upstream. It is the rev the tree + // under test records — always, since #24359 — so the verdict is a function of + // that commit; that the rev is upstream is proved by the arm below wherever + // history is walkable, which is the path this repository's own PR gate runs + // (see `resolveBaselineWithoutMergeBase`). if (rev === resolved.rev) { compareAnchorKeys(resolved.keys, committed, short, fix); return; @@ -2275,117 +2279,99 @@ 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). + * `fetch-depth: 0`, every agent container, and every hermetic rebuild of a + * pinned commit (a sibling repository's CI fetching one commit at `--depth=1`, + * a bisect step, a release re-cut). * - * ── 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 + * ── The rule: the commit under test is judged against what IT records ─────── + * The rev is the `baseRev` of THIS tree's `authorable-surface.base.json`, and + * nothing else. Its keys, its manifest and its defaults are then read out of + * git AT that rev (never out of the file), and `verifyCommittedSurfaceBase` + * compares the file's keys against them, so the file still cannot vouch for + * itself. What the rev is never taken from is origin/main — not its tip, and not + * the rev origin/main's own copy of the anchor names — because either one makes + * the verdict about this commit a function of wherever main happens to point + * when the build runs (#24359). Measured on the commit + * `@objectstack/spec@18.0.0-next.3` was released from, rebuilt in a `--depth=1` clone: green while + * the anchor commit was fetchable and main still named the same anchor, red + * (`3 schema(s) left the published set`, every one of them added on main AFTER + * that release) once the build fell back to main's tip, and red the same way + * under a main that had re-anchored past the release. The schemas a commit is + * judged against must be schemas that commit could have known about. + * + * ── Why the tip was never the right answer ────────────────────────────────── + * Under a TIP anchor, "main added a key after this commit forked" and "this + * commit 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: + * had just ADDED. The rev origin/main's anchor names has the same flaw one step + * removed: it is upstream, but it may be NEWER than the commit under test, which + * narrows the false-positive window without closing it. * - * 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. + * ── What this trades, and where the trade is paid back ────────────────────── + * A truncated history cannot prove that `baseRev` is UPSTREAM: `merge-base + * --is-ancestor` answers "not an ancestor" about a commit that plainly is one, + * so a tree whose `baseRev` names one of its own commits is not refutable here. + * That half of the anchor's authenticity is proved where history is walkable — + * the merge-base path above it, whose `verifyCommittedSurfaceBase` refuses a + * `baseRev` that is not an ancestor of origin/main — and that is the path this + * repository's own PR and merge-queue gate runs (its checkout asks for + * `fetch-depth: 0` precisely so `merge-base` answers). So the ratchet's teeth on + * a PR are where they were; what a shallow job stops doing is overriding the + * commit's own anchor with main's. * - * 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. + * ── With no anchor to read, the run refuses ───────────────────────────────── + * No `authorable-surface.base.json` in the tree, or a `baseRev` this checkout + * cannot read and the remote will not serve: there is no baseline that belongs + * to this commit, and the old answer — anchor on the tip and warn — was a verdict + * about main, not about the commit. A refusal names what is missing instead of + * accusing the commit of a deletion it never made. ⛔ Never a skip: "the + * comparison could not run" must not read as "nothing was removed". */ 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) { +): { rev: string; why: string } | { refusal: string } { + const remedy = + ` A checkout with walkable history anchors on the merge base instead: in CI the\n` + + ` checkout step needs \`fetch-depth: 0\`; locally, \`git fetch --unshallow origin\`.`; + if (!committed) { return { - rev: own, - why: `origin/main's own ${SURFACE_BASE_FILE_NAME} names the same commit, so it is upstream`, + refusal: + `\n❌ No baseline to anchor the deletion checks on: this history is truncated, so\n` + + ` \`merge-base HEAD origin/main\` cannot answer, and this tree carries no\n` + + ` ${SURFACE_BASE_FILE_NAME} — the one record of which upstream commit it is to be\n` + + ` judged against. origin/main's tip is not that commit: it may hold schemas and keys\n` + + ` published after this tree was written, and judging the tree against them reports\n` + + ` main's additions as this tree's deletions.\n\n` + + ` Fix: restore packages/spec/${SURFACE_BASE_FILE_NAME} (it is a committed artifact), or\n` + + ` build from walkable history.\n${remedy}`, }; } - if (upstreamRev && upstreamRev !== own && ensureCommitPresent(git, upstreamRev)) { + const own = committed.baseRev; + if (!ensureCommitPresent(git, own)) { 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`, + refusal: + `\n❌ The deletion checks' anchor ${own.slice(0, 12)} — the commit this tree's ${SURFACE_BASE_FILE_NAME}\n` + + ` records — is not in this checkout and the remote did not serve it (\`git fetch --depth=1\n` + + ` origin ${own}\` failed). With truncated history there is no merge base to use either,\n` + + ` and origin/main's tip is no baseline for this tree: it may hold schemas and keys\n` + + ` published after this tree was written. So this run refuses rather than judge the tree\n` + + ` against main, and rather than skip the checks.\n\n` + + ` Fix: re-run with the remote reachable (the fetch above is all it needs), or build from\n` + + ` walkable history.\n${remedy}`, }; } - return null; + return { + rev: own, + why: + `it is the rev this tree's ${SURFACE_BASE_FILE_NAME} records, so the verdict depends on this\n` + + ` commit alone, never on where origin/main points. That the rev is upstream is proved where\n` + + ` history is walkable (\`fetch-depth: 0\`), which is this repository's own PR gate`, + }; } /** @@ -2430,11 +2416,13 @@ type SurfaceBaseResolution = { * 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). + * `merge-base` has nothing to walk. The rev is then the one THIS tree's + * in-tree anchor records, and the keys are read out of git at it — see + * `resolveBaselineWithoutMergeBase` for why neither origin/main's tip nor + * origin/main's own anchor is eligible (#6452, #24359). Nothing is skipped + * and nothing is waived: with no readable anchor of its own the run + * refuses. Only `--update-base` still reads the tip there, and its write + * refuses in that state. * `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 @@ -2477,43 +2465,45 @@ function resolveSurfaceBase(): SurfaceBaseResolution | null { // 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). + // branch's deletion. Re-anchor instead of re-judging (#6452) — on the anchor + // the commit under test records, so the verdict cannot move with main (#24359). const mergeBase = git('merge-base', 'HEAD', tip); let rev = mergeBase.status === 0 ? mergeBase.stdout.trim() : tip; - if (mergeBase.status !== 0) { - // `--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 + if (mergeBase.status !== 0 && UPDATE_BASE) { + // `--update-base` is the one mode that keeps the tip, and it is not a + // verdict about this commit: its job is to resolve a NEW baseline out of + // git and write it down, so anchoring it on the committed anchor would be + // 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).)`, - ); + console.log( + ` (shallow history — no merge base is walkable here, and --update-base resolves a NEW\n` + + ` baseline out of git, so this run reads origin/main's TIP ${tip.slice(0, 12)}.\n` + + ` ⚠️ Under a tip anchor a key that main ADDED after this branch forked is\n` + + ` indistinguishable from a key this branch DELETED, and moving an existing anchor refuses\n` + + ` where history is truncated. Re-anchor from walkable history: \`fetch-depth: 0\` in CI,\n` + + ` \`git fetch --unshallow origin\` locally.)`, + ); + } else if (mergeBase.status !== 0) { + // Every other run is a verdict about THIS commit, so it is judged against + // the anchor this commit records — never against origin/main's tip or the + // anchor origin/main records, either of which makes the verdict a function + // of where main points today (#24359). See `resolveBaselineWithoutMergeBase`. + const own = resolveBaselineWithoutMergeBase(git, committed?.doc ?? null); + if ('refusal' in own) { + console.error(own.refusal); + process.exit(1); } + rev = own.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)}: ${own.why}.\n` + + ` The same anchor serves the schema removal and default-change checks. Under a tip anchor\n` + + ` "main added a key after this commit was written" and "this commit deleted a key" are the\n` + + ` same fact. The baseline's keys are read from git at that commit, never from\n` + + ` ${SURFACE_BASE_FILE_NAME} itself.`, + ); } const baseline = readSurfaceKeysAtRev( git, From a99d2041d49c66580f208c571529ae8df9cdc9e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 01:38:03 +0000 Subject: [PATCH 2/2] =?UTF-8?q?wip(spec):=20pin=20the=20commit-local=20sha?= =?UTF-8?q?llow=20anchor=20=E2=80=94=20same=20verdict=20under=20a=20main?= =?UTF-8?q?=20that=20moved=20on=20or=20re-anchored,=20refusal=20with=20no?= =?UTF-8?q?=20readable=20anchor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu Co-authored-by: Claude --- .../scripts/build-schemas-check-mode.test.ts | 283 +++++++++++++++--- packages/spec/scripts/build-schemas.ts | 16 +- 2 files changed, 248 insertions(+), 51 deletions(-) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 7204caca8ce..e7a85199438 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -2777,25 +2777,32 @@ describe('build-schemas.ts — the drift notice names the direction it measured // 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. + // #24359 re-based this fixture without changing its subject. Where + // `merge-base` cannot answer at all, a shallow run now anchors on the + // rev the TREE records, so it resolves exactly the committed anchor and + // there is no drift left to describe. The notice's shallow arm is the + // other truncated state: `merge-base` still answers (the agent container + // this repo is worked in is one), while the anchor's own commit sits in + // `.git/shallow` as a grafted root. Here origin/main reaches the fork + // point `older` along a second parent, so the merge base is walkable, + // and `tip` — the rev the anchor names — is grafted, so neither ancestry + // probe between the two 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); + const mainMerge = git( + 'commit-tree', `${mainHead}^{tree}`, '-p', mainHead, '-p', older, + '-m', 'fixture: origin/main also reaches the fork point along a second parent', + ); + git('update-ref', 'refs/remotes/origin/main', mainMerge); 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(), `${mainHead}\n${tip}\n`); + fs.writeFileSync(shallowFile(), `${tip}\n`); expect(git('rev-parse', '--is-shallow-repository')).toBe('true'); + expect(git('merge-base', 'HEAD', mainMerge), 'the merge base must still answer here').toBe(older); const { status, output } = run([]); @@ -2903,14 +2910,22 @@ describe('build-schemas.ts — the drift notice names the direction it measured // 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. +// 4. the anchor is the one the TREE records (#24359). A PR can point `baseRev` +// at one of its OWN commits, and a truncated history cannot refute that by +// walking — so the shallow run uses it, and the forged rev is refused where +// history IS walkable, the path this repository's own PR gate runs +// (`fetch-depth: 0`). Both halves are pinned: the shallow run's anchor, and +// the walkable run's refusal. Before #24359 the shallow run swapped in the +// rev origin/main's anchor named, which made the verdict about a commit a +// function of wherever main pointed; +// 5. with no anchor the tree can read, the run REFUSES — it never anchors on +// the tip again. What origin/main does or does not record is irrelevant; +// 6. the acceptance #24359 is about: the same commit, under a main that only +// moved on and under a main that re-anchored past it, gets the same verdict; +// 7. and that verdict keeps its teeth: a schema the commit's own anchor +// recorded and the build no longer emits is red under either main — where +// the old run, under the re-anchored main, missed it and named one of +// main's additions instead. 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'; @@ -2918,6 +2933,11 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, 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'; + /** A schema only main publishes, AFTER the commit under test (cases 6–7). */ + const MAIN_ADDED_DEF = 'ui/ZzzPublishedOnMainAfterTheCommit'; + /** A schema the commit's own anchor recorded and the commit stopped emitting (case 7). */ + const ANCHOR_ONLY_DEF = 'ui/ZzzPublishedAtTheAnchorOnly'; + const NO_REGISTERED_REMOVAL = 'schema(s) left the published set with no registered removal'; const shallowFile = (): string => path.join(sandbox, '.git', 'shallow'); @@ -2932,6 +2952,11 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, expect(pristineSurface, `${SHED_KEY} is no longer in the baseline — pick another live key`).toContain( SHED_KEY, ); + const registeredDefs = Object.values(RETIRED_DEFS_BY_MAJOR).flat(); + for (const def of [MAIN_ADDED_DEF, ANCHOR_ONLY_DEF]) { + expect(pristine, `${def} is a real published def — pick a phantom`).not.toContain(def); + expect(registeredDefs, `${def} is registered for real — pick an unregistered phantom`).not.toContain(def); + } // Own repo: EVERY case here marks the repo shallow (`forkBranch` writes // `.git/shallow`), which is the widest such window in the file (#9068). ownSandbox = createSandbox('build-schemas-6452-'); @@ -3004,7 +3029,7 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, // 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).toContain("it is the rev this tree's authorable-surface.base.json records"); 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. @@ -3055,16 +3080,14 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, ); it( - 'an anchor rev nothing upstream vouches for is not used — the rev origin/main records is', - { timeout: SPAWN_TIMEOUT_MS }, + 'an anchor rev only this branch vouches for: the shallow run uses it, the walkable run refuses it', + { timeout: SPAWN_TIMEOUT_MS * 2 }, () => { - const { forkBase, anchored, mainTip } = seedUpstream((s) => [...s, BRANCH_DELETED_KEY].sort()); + const { 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. + // surface), upstream against nothing. seedSurface((s) => s); git('add', AUTHORABLE_SURFACE_DIR_NAME); git('commit', '-q', '-m', 'fixture: the branch deletes a baseline key'); @@ -3072,28 +3095,39 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, seedSurfaceBase(branchOwn, (k) => k); git('add', 'authorable-surface.base.json'); git('commit', '-q', '-m', 'fixture: the branch anchors on its own commit'); + + // Truncated. Nothing here can refute the forged rev by walking, and the + // run no longer overrides the tree's anchor with the one main records — + // that override is what made a commit's verdict move with main (#24359). + // The trade, stated rather than hidden: this run is green. 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); + const shallow = run(['--check']); + expect(shallow.output).toContain(`anchors on ${branchOwn.slice(0, 12)} rather than on`); + expect(shallow.output).toContain('That the rev is upstream is proved where'); + expect(shallow.output).not.toContain(BRANCH_DELETED_KEY); + expect(shallow.status).toBe(0); + + // Walkable — the checkout this repository's own PR and merge-queue gate + // asks for (`fetch-depth: 0`). The forged rev is refused there, before any + // baseline is read: this is where the anchor's upstream half is proved. + fs.rmSync(shallowFile(), { force: true }); + expect(git('rev-parse', '--is-shallow-repository')).toBe('false'); + const walkable = run(['--check']); + expect(walkable.status).toBe(1); + expect(walkable.output).toContain('NOT an ancestor of'); + expect(walkable.output).toContain(branchOwn.slice(0, 12)); }, ); it( - 'with no upstream anchor at all it keeps the tip and says so, naming fetch-depth: 0', + "origin/main carrying no anchor changes nothing: the tree's own anchor is the baseline", { 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. + // Main's tree carries no anchor. Before #24359 that left this run with + // nothing it would accept, so it anchored on main's TIP and reported + // MAIN_ADDED_KEY as this branch's deletion. The tree's own anchor was there + // all along, and it is the only one the verdict may depend on. seedManifest((s) => s); const forkBase = seedBase((s) => s); seedSurfaceBase(forkBase, (k) => k); @@ -3106,12 +3140,175 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, 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(`anchors on ${forkBase.slice(0, 12)} rather than on`); + expect(output).not.toContain('anchors on the origin/main TIP'); + expect(output).not.toContain('deleted without proof'); + expect(output).not.toContain(MAIN_ADDED_KEY); + expect(status).toBe(0); + }, + ); + + it( + 'a tree that carries no anchor of its own refuses — the tip is never its baseline', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + seedManifest((s) => s); + const forkBase = seedBase((s) => s); + const mainTip = seedBase((s) => [...s, MAIN_ADDED_KEY].sort()); + forkBranch('issue-6452-tree-without-anchor', forkBase, mainTip); + // The tree under test does not carry the anchor; `afterEach`'s checkout of + // main brings it back for the next case. + fs.rmSync(surfaceBasePath); + + const { status, output } = run(['--check']); + + // Loud, and about what is MISSING — never the old accusation that this + // tree deleted a key main added. + expect(status).toBe(1); + expect(output).toContain('No baseline to anchor the deletion checks on'); + expect(output).toContain('authorable-surface.base.json'); expect(output).toContain('fetch-depth: 0'); + expect(output).not.toContain('anchors on the origin/main TIP'); + expect(output).not.toContain('deleted without proof'); + expect(output).not.toContain(MAIN_ADDED_KEY); + }, + ); + + it( + 'an anchor commit this checkout cannot read refuses — it never falls back to the tip', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // What a `--depth=1` clone sees when the fetch of the anchor commit fails: + // the old run fell back to main's TIP and reported every schema main had + // published since as this commit's removal. + seedManifest((s) => s); + const forkBase = seedBase((s) => s); + const mainTip = seedBase((s) => [...s, MAIN_ADDED_KEY].sort()); + forkBranch('issue-6452-unreadable-anchor', forkBase, mainTip); + // A rev no object store here holds and no remote serves — the fixture + // repository has no remote at all, so the fetch fails as it does offline. + const unreadable = 'f'.repeat(40); + expect(gitStatusIn(sandbox, 'cat-file', '-e', `${unreadable}^{commit}`)).not.toBe(0); + seedSurfaceBase(unreadable, (k) => k); + + const { status, output } = run(['--check']); + expect(status).toBe(1); - expect(output).toContain('deleted without proof'); - expect(output).toContain(MAIN_ADDED_KEY); + expect(output).toContain('is not in this checkout, and the remote did not'); + expect(output).toContain(`git fetch --depth=1 origin ${unreadable}`); + expect(output).not.toContain('anchors on the origin/main TIP'); + expect(output).not.toContain('deleted without proof'); + expect(output).not.toContain(MAIN_ADDED_KEY); + }, + ); + + /** + * Cases 6–7: a commit that is ALREADY on main — a release, say — rebuilt in a + * truncated clone, once under a main that only moved on and once under a main + * that then re-anchored past it. The manifest is tracked here (the rest of this + * block leaves it untracked), because the manifest removal check is the gate + * the reported rebuild failed on. + */ + function seedReleasedCommit(anchorManifest: (s: string[]) => string[]): { + forkBase: string; + underTest: string; + mainMovedOn: string; + mainReanchored: string; + } { + const commitManifest = (mutate: (s: string[]) => string[]): void => { + seedManifest(mutate); + git('add', SCHEMA_MANIFEST_DIR_NAME); + }; + // The commit's upstream base, and the commit itself: its anchor names that base. + commitManifest(anchorManifest); + const forkBase = seedBase((s) => s); + commitManifest((s) => s); + seedSurfaceBase(forkBase, (k) => k); + git('add', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: the commit under test, anchored at its base'); + const underTest = git('rev-parse', 'HEAD'); + // Main moves on: it publishes a schema and a key the commit never had… + commitManifest((s) => [...s, MAIN_ADDED_DEF].sort()); + const mainMovedOn = seedBase((s) => [...s, MAIN_ADDED_KEY].sort()); + // …and then re-anchors on its own tip, the way `--update-base` does. + seedSurfaceBase(mainMovedOn, (k) => [...k, MAIN_ADDED_KEY].sort()); + git('add', 'authorable-surface.base.json'); + git('commit', '-q', '-m', 'fixture: main re-anchors past the commit under test'); + const mainReanchored = git('rev-parse', 'HEAD'); + return { forkBase, underTest, mainMovedOn, mainReanchored }; + } + + /** Build `underTest` in a clone truncated at `originMain`, which origin/main names. */ + function judgeUnder(underTest: string, originMain: string): { status: number; output: string } { + git('update-ref', 'refs/remotes/origin/main', originMain); + forkBranch(`issue-24359-under-${originMain.slice(0, 7)}`, underTest, originMain); + try { + return run(['--check']); + } finally { + fs.rmSync(shallowFile(), { force: true }); + } + } + + /** Hand the block back with the manifest untracked again, as every other case expects. */ + function untrackManifest(): void { + git('checkout', '-q', '-f', 'main'); + git('rm', '-r', '-q', '--cached', SCHEMA_MANIFEST_DIR_NAME); + seedManifest((s) => s); + git('commit', '-q', '-m', 'fixture: the manifest is untracked again'); + } + + it( + 'the verdict belongs to the commit: the same tree is judged alike under a main that re-anchored past it', + { timeout: SPAWN_TIMEOUT_MS * 2 }, + () => { + const { forkBase, underTest, mainMovedOn, mainReanchored } = seedReleasedCommit((s) => s); + try { + const movedOn = judgeUnder(underTest, mainMovedOn); + const reanchored = judgeUnder(underTest, mainReanchored); + + // Before #24359 the second run anchored on the rev main's anchor names + // and exited 1: `1 schema(s) left the published set with no registered + // removal`, naming MAIN_ADDED_DEF — a schema this commit never had. + for (const { status, output } of [movedOn, reanchored]) { + expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`); + expect(output).not.toContain(NO_REGISTERED_REMOVAL); + expect(output).not.toContain(MAIN_ADDED_DEF); + expect(output).not.toContain(MAIN_ADDED_KEY); + expect(output).not.toContain('deleted without proof'); + // …and green because the comparison RAN, not for want of a baseline. + expect(output).not.toContain('removal check: no'); + expect(status).toBe(0); + } + } finally { + untrackManifest(); + } + }, + ); + + it( + "the shallow verdict keeps its teeth: a schema the commit's anchor recorded and the build dropped is red under either main", + { timeout: SPAWN_TIMEOUT_MS * 2 }, + () => { + // The #4725 bypass shape: published at the commit's anchor, gone from the + // build AND from the commit's own manifest, registered nowhere. + const { forkBase, underTest, mainMovedOn, mainReanchored } = seedReleasedCommit((s) => + [...s, ANCHOR_ONLY_DEF].sort(), + ); + try { + // Under the re-anchored main the old run compared against main's anchor, + // which no longer lists ANCHOR_ONLY_DEF — so it MISSED this removal and + // reported MAIN_ADDED_DEF instead. + for (const originMain of [mainMovedOn, mainReanchored]) { + const { status, output } = judgeUnder(underTest, originMain); + expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`); + expect(status).toBe(1); + expect(output).toContain(`1 ${NO_REGISTERED_REMOVAL}`); + expect(output).toContain(` - json-schema/${ANCHOR_ONLY_DEF}.json`); + expect(output).not.toContain(MAIN_ADDED_DEF); + } + } finally { + untrackManifest(); + } }, ); }); diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 525bcddb80a..48aab23a9e7 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -2355,14 +2355,14 @@ function resolveBaselineWithoutMergeBase( if (!ensureCommitPresent(git, own)) { return { refusal: - `\n❌ The deletion checks' anchor ${own.slice(0, 12)} — the commit this tree's ${SURFACE_BASE_FILE_NAME}\n` + - ` records — is not in this checkout and the remote did not serve it (\`git fetch --depth=1\n` + - ` origin ${own}\` failed). With truncated history there is no merge base to use either,\n` + - ` and origin/main's tip is no baseline for this tree: it may hold schemas and keys\n` + - ` published after this tree was written. So this run refuses rather than judge the tree\n` + - ` against main, and rather than skip the checks.\n\n` + - ` Fix: re-run with the remote reachable (the fetch above is all it needs), or build from\n` + - ` walkable history.\n${remedy}`, + `\n❌ The deletion checks' anchor ${own.slice(0, 12)} — the commit this tree's\n` + + ` ${SURFACE_BASE_FILE_NAME} records — is not in this checkout, and the remote did not\n` + + ` serve it:\n\n` + + ` git fetch --depth=1 origin ${own}\n\n` + + ` With truncated history there is no merge base to use either, and origin/main's tip is no\n` + + ` baseline for this tree: it may hold schemas and keys published after this tree was\n` + + ` written. So this run refuses, rather than judge the tree against main or skip the checks.\n\n` + + ` Fix: re-run where that fetch succeeds, or build from walkable history.\n${remedy}`, }; } return {