Skip to content

Commit d9d41dd

Browse files
fix(spec): a truncated-history build judges a commit by the anchor it records, never by origin/main (#24436)
Fixes #24359 Clause-②: no ## What changed `packages/spec/scripts/build-schemas.ts` computes ONE baseline for its three git-reading ratchets: the schema removal check, the authorable-key deletion check and the default-change check. Where `merge-base HEAD origin/main` answers, nothing changes: that is the path this repository's own PR and merge-queue gate runs (`Type Check · source gates` checks out with `fetch-depth: 0`). Where history is truncated, which is every `--depth=1` rebuild of a pinned commit, the old resolver picked its anchor in this order: 1. the tree's own `authorable-surface.base.json` `baseRev`, but only if origin/main vouched for it; 2. otherwise the rev **origin/main's** anchor names; 3. otherwise **origin/main's tip**. Arms 2 and 3 make the verdict about commit X a function of where main points when the build runs. Now a truncated history always anchors on the rev **the tree under test records**. Its keys, manifest and defaults are still read out of git at that rev (never out of the file), and the file is still checked against them. With no anchor the tree can read, the run refuses: either the file is missing, or its `baseRev` cannot be fetched. It never falls back to the tip. `--update-base` is the one mode that still reads the tip in a truncated history, unchanged; moving an existing anchor still refuses there (the forward-only rule). Per ratchet in the file (measured, not assumed): - the manifest removal check, the authorable-key deletion check and the default-change check all read the single `resolveSurfaceBase()` resolution, so all three share the defect and all three get this change at once; - the never-published ratchet (`lib/unemitted-schemas`) and the dropped-refinement ratchet read only in-tree baselines (no git reads), so neither shares the defect and neither changes. ## Measured: the release commit, rebuilt the way objectui rebuilds it The commit `@objectstack/spec@18.0.0-next.3` was released from, `2c9bf9430a`, rebuilt with `pnpm gen:schema` in an objectui-shaped clone: `git init`, a remote, a depth-1 fetch of the pin. Its `build-schemas.ts` is byte-identical to main's, so "after" means that commit with this script copied in. Every leg's anchor rev is `85c6d76ec42a` unless stated otherwise. | remote the clone fetches `main` from | before (script `a266dcd7e296`) | after (script `48aab23a9e72`) | |---|---|---| | real GitHub main (`71fa166e96` before, `e22defb13c` after) | exit 0, anchored on `85c6d76ec42a` (main still names the same anchor) | exit 0, anchored on `85c6d76ec42a` | | a main whose anchor commit cannot be fetched (models the objectui CI jobs; see notes) | **exit 1**: anchors on the TIP; `3 schema(s) left the published set with no registered removal`: `api/PackageInstallManifest`, `security/DefaultRowScope`, `ui/PageRecordBinding`, all added on main after the release | exit 1, an accurate refusal: the anchor commit was not served, so the run refuses rather than judge the tree against main or skip the checks | | a main that re-anchored past the release (synthetic commit `07857fe78f` on the tip, as `--update-base` writes it) | **exit 1**: anchors on main's anchor rev `71fa166e965a`; the same 3 schemas | exit 0, anchored on `85c6d76ec42a` | | no remote (objectui's workaround) | exit 0, offline in-tree anchor | exit 0, offline in-tree anchor | So for every origin/main (the real tip, a re-anchored main, none) the release commit gets the same verdict on the same anchor. The only red after the change is a remote that will not serve the commit's own anchor, and that red says exactly that. objectui's real failure (PR objectui#12963, the `Type Check` job's annotation) matches the before/fetch-failure leg message for message. First "no upstream anchor was usable either … anchors on the origin/main TIP `51ad8e4fba87`", then `❌ 2 schema(s) left the published set …`. At that tip main's own anchor also named `85c6d76ec42a`, so the old resolver's arm 1 was lost only because the anchor fetch failed in that job. ## Ruling ③: the ratchet does not weaken where it guards The ratchet's own refusal pins (the `a deleted manifest key must prove itself` block): the bypass repro is **red** on a removal, a declared removal is **green**, and the negative control is green. - before, at BASE `71fa166e96`: `Tests 3 passed` - after, at HEAD `a99d2041d4`: the whole block, `9 passed`, inside a 28/28 run Those cases run on walkable history, the PR gate's path, which this change does not touch. New in the shallow block, a commit that is already on main, rebuilt in a truncated clone: - **same verdict:** under a main that only moved on and under a main that re-anchored past it, both runs are green on the commit's own anchor, and the removal comparison really ran; - **teeth:** a schema the commit's anchor recorded and the build dropped, unregistered, is **red under either main**, naming that schema. Under the re-anchored main the old resolver missed this removal and named one of main's additions instead. ## The trade, stated A truncated history cannot prove `baseRev` is upstream, so a branch that points `baseRev` at its own commit is no longer overridden with main's anchor in a shallow job. That forged rev is refused where history is walkable: `NOT an ancestor of origin/main`, the existing authenticity check, which runs on this repository's PR and merge-queue gate. Both halves are pinned in one case: the shallow run uses the tree's rev, and the walkable run refuses it. ## Tests (all at HEAD `a99d2041d4`) - `build-schemas-check-mode.test.ts` (vitest `repo` project; the file is about 20 minutes long, so it ran in four `-t` slices): **92 of 92 passed** (13 + 28 + 29 + 22). - `def-key-collisions.test.ts`, `published-projection-choke-point.test.ts` and `schema-tree-freshness.test.ts` (they spawn, read or digest this script): 35/35 passed. - `@objectstack/spec` local project: 696 files, 21251 tests passed. - `pnpm --filter @objectstack/spec typecheck`: exit 0. `tsc -p tsconfig.scripts.json --listFiles` includes both changed files. - **Ablation**, one-shot. BASE's `build-schemas.ts` was put on disk (hash `a266dcd7e296…` matched BASE's blob), the shallow block ran, then the file was restored (hash `48aab23a9e72…` matched HEAD's blob, `git diff HEAD` empty). Result: **7 failed, 2 passed**. The 2 are the unchanged cases (a real deletion stays red; keys come from git, not the file). The two new cases failed on the old resolver exactly as described above, with `1 schema(s) left the published set … ui/ZzzPublishedOnMainAfterTheCommit`. No `dist/` leg: the test copies `scripts/` and runs it with tsx. - One fixture in the drift-notice block was re-based without changing its subject. Its shallow state now keeps `merge-base` answerable, because a shallow run with no merge base now anchors on exactly the committed anchor and has no drift left to describe. ## Gates (local, derived with `dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `a99d2041d4`) `--ran` reconciliation: **65 derived, 60 run, 5 NOT-MEASURED, 0 UNRUN** (exit 0). - 60 ran, every one exit 0. This includes the five spec gates (`check:authorable-surface`, `check:empty-state`, `check:liveness`, `check:strictness-ledger`, `check:variant-docs`), `check:nul-bytes`, `check:published-files`, `check:changeset-gate-self-tests`, `check:adr-0087-registration --base origin/main`, and `check:pm-dispatch-gates`. That battery ran detached; its verdict line was `1913 cases pass` (fast tier). - NOT MEASURED, `check:dts-closure`, `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:sourcemap-no-sources-content`: exit 3, prerequisite not met (no workspace `dist/` in this worktree). - NOT MEASURED, `check:type-check-debt`: it re-measures `tsc` for every package under a closure build and exceeded a 420 s bound (exit 124). CI runs all five NOT MEASURED families. ## Changeset: `skip-changeset` `packages/spec/scripts/**` is outside `files[]`. `npm pack --dry-run` lists 0 `scripts/` entries. Measured: none of this diff's symbols or strings appears in any `files[]` path, nor in `dist/` (each 0 files). Positive controls do hit: `x-unprojectable-branches`, which this script writes into `json-schema/`, is in 6 files; `PageRecordBinding` is in 22 `dist/` files. The one shipped byte sequence that moves is `json-schema/.build-input-hash-schema`, a freshness digest whose inputs include `scripts/**`, as they do for any test-only spec change. The precedent is `0d43004100`, which changed only this script and carried no changeset. ## Acceptance notes - **Applies to commits that carry it.** A rebuild runs the script of the commit being built. The 18.0.0-next.3 commit keeps its own resolver forever, so objectui's remote-drop workaround stays needed until its pin moves to a commit carrying this change. Retiring the workaround is objectui's. - **Boundary, unchanged:** a full clone whose origin/main is OLDER than the commit's own `baseRev` still refuses with `NOT an ancestor of origin/main`. Stale origin/main and a forged anchor are indistinguishable without history, and that refusal is the guard. Noted, not filed; the carrier is none. - **Inferred, not measured:** why the anchor fetch failed in 13 of objectui's 25 jobs. The annotations show the fetch failed in the re-anchor call and succeeded moments later in the authenticity check. The job logs are on a blob host this container cannot reach (403). - The origin/main the tree was compared against moved 2 commits after BASE, neither touching `packages/spec/`; the branch was not merged with main. --- _Generated by [Claude Code](https://claude.ai/code/session_01WnW7HvfnfHbLg1fpkHX3Mu)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9112bcd commit d9d41dd

2 files changed

Lines changed: 362 additions & 175 deletions

File tree

‎packages/spec/scripts/build-schemas-check-mode.test.ts‎

Lines changed: 240 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2777,25 +2777,32 @@ describe('build-schemas.ts — the drift notice names the direction it measured
27772777
// same assumption printed the exact opposite of the truth. Declining is the
27782778
// same disposition #5370 already took for the write.
27792779
//
2780-
// #6452 re-based this fixture without changing its subject. The baseline a
2781-
// shallow run resolves is no longer origin/main's TIP (that is what made
2782-
// main's additions read as this branch's deletions), so the fixture now
2783-
// says on MAIN which upstream rev the anchor names — `older` — instead of
2784-
// inheriting whichever anchor an earlier case left behind. And BOTH fetched
2785-
// revs are grafted, which is what a checkout that fetched two commits at
2786-
// `--depth=1` actually looks like: with `tip` a shallow root too, neither
2787-
// ancestry probe can answer and the notice must still decline.
2780+
// #24359 re-based this fixture without changing its subject. Where
2781+
// `merge-base` cannot answer at all, a shallow run now anchors on the
2782+
// rev the TREE records, so it resolves exactly the committed anchor and
2783+
// there is no drift left to describe. The notice's shallow arm is the
2784+
// other truncated state: `merge-base` still answers (the agent container
2785+
// this repo is worked in is one), while the anchor's own commit sits in
2786+
// `.git/shallow` as a grafted root. Here origin/main reaches the fork
2787+
// point `older` along a second parent, so the merge base is walkable,
2788+
// and `tip` — the rev the anchor names — is grafted, so neither ancestry
2789+
// probe between the two can answer and the notice must still decline.
27882790
seedSurfaceBase(older, (k) => k.filter((x) => x !== AHEAD_KEY && x !== LANDED_KEY));
27892791
git('add', 'authorable-surface.base.json');
27902792
git('commit', '-q', '-m', 'fixture: main records its anchor at the older baseline');
27912793
const mainHead = git('rev-parse', 'HEAD');
2792-
git('update-ref', 'refs/remotes/origin/main', mainHead);
2794+
const mainMerge = git(
2795+
'commit-tree', `${mainHead}^{tree}`, '-p', mainHead, '-p', older,
2796+
'-m', 'fixture: origin/main also reaches the fork point along a second parent',
2797+
);
2798+
git('update-ref', 'refs/remotes/origin/main', mainMerge);
27932799

27942800
git('checkout', '-q', '-B', 'issue-5847-shallow', older);
27952801
seedSurface((s) => s);
27962802
const anchorAtTip = commitAnchor(tip, (k) => k.filter((x) => x !== LANDED_KEY));
2797-
fs.writeFileSync(shallowFile(), `${mainHead}\n${tip}\n`);
2803+
fs.writeFileSync(shallowFile(), `${tip}\n`);
27982804
expect(git('rev-parse', '--is-shallow-repository')).toBe('true');
2805+
expect(git('merge-base', 'HEAD', mainMerge), 'the merge base must still answer here').toBe(older);
27992806

28002807
const { status, output } = run([]);
28012808

@@ -2903,21 +2910,34 @@ describe('build-schemas.ts — the drift notice names the direction it measured
29032910
// resolution took the file's own `keys`, `verifyCommittedSurfaceBase` would
29042911
// hit its `rev === resolved.rev` fast path and compare the anchor against
29052912
// itself, so a line shed from it would pass. The fixture sheds one;
2906-
// 4. an anchor rev nothing upstream vouches for is not used — a PR can point
2907-
// `baseRev` at one of its OWN commits (a `--depth=1` fetch resolves any sha
2908-
// the remote advertises), and a truncated history cannot refute it by
2909-
// walking, so the rev is accepted only when origin/main's own copy of the
2910-
// anchor names it (or reachability is demonstrated outright);
2911-
// 5. with no upstream anchor at all the run keeps the tip and SAYS so, naming
2912-
// `fetch-depth: 0`. That residual false red is the honest degradation, and
2913-
// it is pinned so it stays loud rather than becoming a silent skip.
2913+
// 4. the anchor is the one the TREE records (#24359). A PR can point `baseRev`
2914+
// at one of its OWN commits, and a truncated history cannot refute that by
2915+
// walking — so the shallow run uses it, and the forged rev is refused where
2916+
// history IS walkable, the path this repository's own PR gate runs
2917+
// (`fetch-depth: 0`). Both halves are pinned: the shallow run's anchor, and
2918+
// the walkable run's refusal. Before #24359 the shallow run swapped in the
2919+
// rev origin/main's anchor named, which made the verdict about a commit a
2920+
// function of wherever main pointed;
2921+
// 5. with no anchor the tree can read, the run REFUSES — it never anchors on
2922+
// the tip again. What origin/main does or does not record is irrelevant;
2923+
// 6. the acceptance #24359 is about: the same commit, under a main that only
2924+
// moved on and under a main that re-anchored past it, gets the same verdict;
2925+
// 7. and that verdict keeps its teeth: a schema the commit's own anchor
2926+
// recorded and the build no longer emits is red under either main — where
2927+
// the old run, under the re-anchored main, missed it and named one of
2928+
// main's additions instead.
29142929
describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, it does not accuse (#6452)', () => {
29152930
/** Only in the baseline at origin/main's TIP: what main added after this branch forked. */
29162931
const MAIN_ADDED_KEY = 'data/Object:zzAddedOnMainAfterTheFork6452';
29172932
/** In the baseline at the ANCHORED rev too, so its absence is a real deletion. */
29182933
const BRANCH_DELETED_KEY = 'data/Object:zzDeletedByThisBranch6452';
29192934
/** A live key this build really emits — shed from the anchor FILE by case 3. */
29202935
const SHED_KEY = 'data/Object:label';
2936+
/** A schema only main publishes, AFTER the commit under test (cases 6–7). */
2937+
const MAIN_ADDED_DEF = 'ui/ZzzPublishedOnMainAfterTheCommit';
2938+
/** A schema the commit's own anchor recorded and the commit stopped emitting (case 7). */
2939+
const ANCHOR_ONLY_DEF = 'ui/ZzzPublishedAtTheAnchorOnly';
2940+
const NO_REGISTERED_REMOVAL = 'schema(s) left the published set with no registered removal';
29212941

29222942
const shallowFile = (): string => path.join(sandbox, '.git', 'shallow');
29232943

@@ -2932,6 +2952,11 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate,
29322952
expect(pristineSurface, `${SHED_KEY} is no longer in the baseline — pick another live key`).toContain(
29332953
SHED_KEY,
29342954
);
2955+
const registeredDefs = Object.values(RETIRED_DEFS_BY_MAJOR).flat();
2956+
for (const def of [MAIN_ADDED_DEF, ANCHOR_ONLY_DEF]) {
2957+
expect(pristine, `${def} is a real published def — pick a phantom`).not.toContain(def);
2958+
expect(registeredDefs, `${def} is registered for real — pick an unregistered phantom`).not.toContain(def);
2959+
}
29352960
// Own repo: EVERY case here marks the repo shallow (`forkBranch` writes
29362961
// `.git/shallow`), which is the widest such window in the file (#9068).
29372962
ownSandbox = createSandbox('build-schemas-6452-');
@@ -3004,7 +3029,7 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate,
30043029

30053030
// The anchor moved, and the line says which rev and on whose authority.
30063031
expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`);
3007-
expect(output).toContain("origin/main's own authorable-surface.base.json names the same commit");
3032+
expect(output).toContain("it is the rev this tree's authorable-surface.base.json records");
30083033
expect(output).not.toContain('no merge base is walkable here');
30093034
// The defect itself: under the tip anchor this run exited 1 naming
30103035
// MAIN_ADDED_KEY as an unproven deletion.
@@ -3055,45 +3080,54 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate,
30553080
);
30563081

30573082
it(
3058-
'an anchor rev nothing upstream vouches for is not used — the rev origin/main records is',
3059-
{ timeout: SPAWN_TIMEOUT_MS },
3083+
'an anchor rev only this branch vouches for: the shallow run uses it, the walkable run refuses it',
3084+
{ timeout: SPAWN_TIMEOUT_MS * 2 },
30603085
() => {
3061-
const { forkBase, anchored, mainTip } = seedUpstream((s) => [...s, BRANCH_DELETED_KEY].sort());
3086+
const { anchored, mainTip } = seedUpstream((s) => [...s, BRANCH_DELETED_KEY].sort());
30623087
git('checkout', '-q', '-B', 'issue-6452-unvouched', anchored);
30633088
// The branch deletes the key, commits it, and then points `baseRev` at its
30643089
// OWN commit — authentic against itself (its keys ARE that commit's
3065-
// surface), upstream against nothing. A truncated history cannot refute it
3066-
// by walking, which is why the rev has to be vouched for rather than merely
3067-
// checked.
3090+
// surface), upstream against nothing.
30683091
seedSurface((s) => s);
30693092
git('add', AUTHORABLE_SURFACE_DIR_NAME);
30703093
git('commit', '-q', '-m', 'fixture: the branch deletes a baseline key');
30713094
const branchOwn = git('rev-parse', 'HEAD');
30723095
seedSurfaceBase(branchOwn, (k) => k);
30733096
git('add', 'authorable-surface.base.json');
30743097
git('commit', '-q', '-m', 'fixture: the branch anchors on its own commit');
3098+
3099+
// Truncated. Nothing here can refute the forged rev by walking, and the
3100+
// run no longer overrides the tree's anchor with the one main records —
3101+
// that override is what made a commit's verdict move with main (#24359).
3102+
// The trade, stated rather than hidden: this run is green.
30753103
fs.writeFileSync(shallowFile(), `${mainTip}\n`);
30763104
expect(mergeBaseFails(mainTip)).toBe(true);
3077-
3078-
const { status, output } = run(['--check']);
3079-
3080-
expect(output).toContain(`names ${branchOwn.slice(0, 12)}, which nothing here can`);
3081-
expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`);
3082-
// …and the deletion the forged anchor was hiding is still adjudicated.
3083-
expect(status).toBe(1);
3084-
expect(output).toContain('deleted without proof (#4650)');
3085-
expect(output).toContain(BRANCH_DELETED_KEY);
3105+
const shallow = run(['--check']);
3106+
expect(shallow.output).toContain(`anchors on ${branchOwn.slice(0, 12)} rather than on`);
3107+
expect(shallow.output).toContain('That the rev is upstream is proved where');
3108+
expect(shallow.output).not.toContain(BRANCH_DELETED_KEY);
3109+
expect(shallow.status).toBe(0);
3110+
3111+
// Walkable — the checkout this repository's own PR and merge-queue gate
3112+
// asks for (`fetch-depth: 0`). The forged rev is refused there, before any
3113+
// baseline is read: this is where the anchor's upstream half is proved.
3114+
fs.rmSync(shallowFile(), { force: true });
3115+
expect(git('rev-parse', '--is-shallow-repository')).toBe('false');
3116+
const walkable = run(['--check']);
3117+
expect(walkable.status).toBe(1);
3118+
expect(walkable.output).toContain('NOT an ancestor of');
3119+
expect(walkable.output).toContain(branchOwn.slice(0, 12));
30863120
},
30873121
);
30883122

30893123
it(
3090-
'with no upstream anchor at all it keeps the tip and says so, naming fetch-depth: 0',
3124+
"origin/main carrying no anchor changes nothing: the tree's own anchor is the baseline",
30913125
{ timeout: SPAWN_TIMEOUT_MS },
30923126
() => {
3093-
// The honest degradation, pinned so it stays LOUD. A main whose tree carries
3094-
// no anchor cannot vouch for anything, so this run has only the tip — and
3095-
// the tip anchor is the defect. It reports the residual false red instead of
3096-
// waiving the check, because a diagnosable false red beats a silent bypass.
3127+
// Main's tree carries no anchor. Before #24359 that left this run with
3128+
// nothing it would accept, so it anchored on main's TIP and reported
3129+
// MAIN_ADDED_KEY as this branch's deletion. The tree's own anchor was there
3130+
// all along, and it is the only one the verdict may depend on.
30973131
seedManifest((s) => s);
30983132
const forkBase = seedBase((s) => s);
30993133
seedSurfaceBase(forkBase, (k) => k);
@@ -3106,12 +3140,175 @@ describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate,
31063140

31073141
const { status, output } = run(['--check']);
31083142

3109-
expect(output).toContain('no merge base is walkable here, and no upstream anchor was usable');
3110-
expect(output).toContain(`anchors on the origin/main TIP ${mainTip.slice(0, 12)} instead`);
3143+
expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`);
3144+
expect(output).not.toContain('anchors on the origin/main TIP');
3145+
expect(output).not.toContain('deleted without proof');
3146+
expect(output).not.toContain(MAIN_ADDED_KEY);
3147+
expect(status).toBe(0);
3148+
},
3149+
);
3150+
3151+
it(
3152+
'a tree that carries no anchor of its own refuses — the tip is never its baseline',
3153+
{ timeout: SPAWN_TIMEOUT_MS },
3154+
() => {
3155+
seedManifest((s) => s);
3156+
const forkBase = seedBase((s) => s);
3157+
const mainTip = seedBase((s) => [...s, MAIN_ADDED_KEY].sort());
3158+
forkBranch('issue-6452-tree-without-anchor', forkBase, mainTip);
3159+
// The tree under test does not carry the anchor; `afterEach`'s checkout of
3160+
// main brings it back for the next case.
3161+
fs.rmSync(surfaceBasePath);
3162+
3163+
const { status, output } = run(['--check']);
3164+
3165+
// Loud, and about what is MISSING — never the old accusation that this
3166+
// tree deleted a key main added.
3167+
expect(status).toBe(1);
3168+
expect(output).toContain('No baseline to anchor the deletion checks on');
3169+
expect(output).toContain('authorable-surface.base.json');
31113170
expect(output).toContain('fetch-depth: 0');
3171+
expect(output).not.toContain('anchors on the origin/main TIP');
3172+
expect(output).not.toContain('deleted without proof');
3173+
expect(output).not.toContain(MAIN_ADDED_KEY);
3174+
},
3175+
);
3176+
3177+
it(
3178+
'an anchor commit this checkout cannot read refuses — it never falls back to the tip',
3179+
{ timeout: SPAWN_TIMEOUT_MS },
3180+
() => {
3181+
// What a `--depth=1` clone sees when the fetch of the anchor commit fails:
3182+
// the old run fell back to main's TIP and reported every schema main had
3183+
// published since as this commit's removal.
3184+
seedManifest((s) => s);
3185+
const forkBase = seedBase((s) => s);
3186+
const mainTip = seedBase((s) => [...s, MAIN_ADDED_KEY].sort());
3187+
forkBranch('issue-6452-unreadable-anchor', forkBase, mainTip);
3188+
// A rev no object store here holds and no remote serves — the fixture
3189+
// repository has no remote at all, so the fetch fails as it does offline.
3190+
const unreadable = 'f'.repeat(40);
3191+
expect(gitStatusIn(sandbox, 'cat-file', '-e', `${unreadable}^{commit}`)).not.toBe(0);
3192+
seedSurfaceBase(unreadable, (k) => k);
3193+
3194+
const { status, output } = run(['--check']);
3195+
31123196
expect(status).toBe(1);
3113-
expect(output).toContain('deleted without proof');
3114-
expect(output).toContain(MAIN_ADDED_KEY);
3197+
expect(output).toContain('is not in this checkout, and the remote did not');
3198+
expect(output).toContain(`git fetch --depth=1 origin ${unreadable}`);
3199+
expect(output).not.toContain('anchors on the origin/main TIP');
3200+
expect(output).not.toContain('deleted without proof');
3201+
expect(output).not.toContain(MAIN_ADDED_KEY);
3202+
},
3203+
);
3204+
3205+
/**
3206+
* Cases 6–7: a commit that is ALREADY on main — a release, say — rebuilt in a
3207+
* truncated clone, once under a main that only moved on and once under a main
3208+
* that then re-anchored past it. The manifest is tracked here (the rest of this
3209+
* block leaves it untracked), because the manifest removal check is the gate
3210+
* the reported rebuild failed on.
3211+
*/
3212+
function seedReleasedCommit(anchorManifest: (s: string[]) => string[]): {
3213+
forkBase: string;
3214+
underTest: string;
3215+
mainMovedOn: string;
3216+
mainReanchored: string;
3217+
} {
3218+
const commitManifest = (mutate: (s: string[]) => string[]): void => {
3219+
seedManifest(mutate);
3220+
git('add', SCHEMA_MANIFEST_DIR_NAME);
3221+
};
3222+
// The commit's upstream base, and the commit itself: its anchor names that base.
3223+
commitManifest(anchorManifest);
3224+
const forkBase = seedBase((s) => s);
3225+
commitManifest((s) => s);
3226+
seedSurfaceBase(forkBase, (k) => k);
3227+
git('add', 'authorable-surface.base.json');
3228+
git('commit', '-q', '-m', 'fixture: the commit under test, anchored at its base');
3229+
const underTest = git('rev-parse', 'HEAD');
3230+
// Main moves on: it publishes a schema and a key the commit never had…
3231+
commitManifest((s) => [...s, MAIN_ADDED_DEF].sort());
3232+
const mainMovedOn = seedBase((s) => [...s, MAIN_ADDED_KEY].sort());
3233+
// …and then re-anchors on its own tip, the way `--update-base` does.
3234+
seedSurfaceBase(mainMovedOn, (k) => [...k, MAIN_ADDED_KEY].sort());
3235+
git('add', 'authorable-surface.base.json');
3236+
git('commit', '-q', '-m', 'fixture: main re-anchors past the commit under test');
3237+
const mainReanchored = git('rev-parse', 'HEAD');
3238+
return { forkBase, underTest, mainMovedOn, mainReanchored };
3239+
}
3240+
3241+
/** Build `underTest` in a clone truncated at `originMain`, which origin/main names. */
3242+
function judgeUnder(underTest: string, originMain: string): { status: number; output: string } {
3243+
git('update-ref', 'refs/remotes/origin/main', originMain);
3244+
forkBranch(`issue-24359-under-${originMain.slice(0, 7)}`, underTest, originMain);
3245+
try {
3246+
return run(['--check']);
3247+
} finally {
3248+
fs.rmSync(shallowFile(), { force: true });
3249+
}
3250+
}
3251+
3252+
/** Hand the block back with the manifest untracked again, as every other case expects. */
3253+
function untrackManifest(): void {
3254+
git('checkout', '-q', '-f', 'main');
3255+
git('rm', '-r', '-q', '--cached', SCHEMA_MANIFEST_DIR_NAME);
3256+
seedManifest((s) => s);
3257+
git('commit', '-q', '-m', 'fixture: the manifest is untracked again');
3258+
}
3259+
3260+
it(
3261+
'the verdict belongs to the commit: the same tree is judged alike under a main that re-anchored past it',
3262+
{ timeout: SPAWN_TIMEOUT_MS * 2 },
3263+
() => {
3264+
const { forkBase, underTest, mainMovedOn, mainReanchored } = seedReleasedCommit((s) => s);
3265+
try {
3266+
const movedOn = judgeUnder(underTest, mainMovedOn);
3267+
const reanchored = judgeUnder(underTest, mainReanchored);
3268+
3269+
// Before #24359 the second run anchored on the rev main's anchor names
3270+
// and exited 1: `1 schema(s) left the published set with no registered
3271+
// removal`, naming MAIN_ADDED_DEF — a schema this commit never had.
3272+
for (const { status, output } of [movedOn, reanchored]) {
3273+
expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`);
3274+
expect(output).not.toContain(NO_REGISTERED_REMOVAL);
3275+
expect(output).not.toContain(MAIN_ADDED_DEF);
3276+
expect(output).not.toContain(MAIN_ADDED_KEY);
3277+
expect(output).not.toContain('deleted without proof');
3278+
// …and green because the comparison RAN, not for want of a baseline.
3279+
expect(output).not.toContain('removal check: no');
3280+
expect(status).toBe(0);
3281+
}
3282+
} finally {
3283+
untrackManifest();
3284+
}
3285+
},
3286+
);
3287+
3288+
it(
3289+
"the shallow verdict keeps its teeth: a schema the commit's anchor recorded and the build dropped is red under either main",
3290+
{ timeout: SPAWN_TIMEOUT_MS * 2 },
3291+
() => {
3292+
// The #4725 bypass shape: published at the commit's anchor, gone from the
3293+
// build AND from the commit's own manifest, registered nowhere.
3294+
const { forkBase, underTest, mainMovedOn, mainReanchored } = seedReleasedCommit((s) =>
3295+
[...s, ANCHOR_ONLY_DEF].sort(),
3296+
);
3297+
try {
3298+
// Under the re-anchored main the old run compared against main's anchor,
3299+
// which no longer lists ANCHOR_ONLY_DEF — so it MISSED this removal and
3300+
// reported MAIN_ADDED_DEF instead.
3301+
for (const originMain of [mainMovedOn, mainReanchored]) {
3302+
const { status, output } = judgeUnder(underTest, originMain);
3303+
expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`);
3304+
expect(status).toBe(1);
3305+
expect(output).toContain(`1 ${NO_REGISTERED_REMOVAL}`);
3306+
expect(output).toContain(` - json-schema/${ANCHOR_ONLY_DEF}.json`);
3307+
expect(output).not.toContain(MAIN_ADDED_DEF);
3308+
}
3309+
} finally {
3310+
untrackManifest();
3311+
}
31153312
},
31163313
);
31173314
});

0 commit comments

Comments
 (0)