Skip to content

Commit d9b24bb

Browse files
fix(spec): re-anchor the authorable-surface deletion gate when merge-base cannot answer (#6452)
`resolveSurfaceBase()` fell back to origin/main's TIP whenever `merge-base HEAD origin/main` failed. 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: PR #6356 touched no packages/spec file and was told it had deleted ui/BulkActionDef:requiredPermissions). The anchor moves; the verdict does not. When merge-base cannot answer, the baseline rev now comes from an upstream anchor rev and its keys are read out of git at that commit — never out of authorable-surface.base.json itself. The rev is accepted only when something the PR does not control vouches for it: demonstrated reachability, or origin/main's own committed copy of the anchor naming the same commit; otherwise the rev origin/main records is used. With no upstream anchor at all the tip is kept and the run says so, naming `fetch-depth: 0`. Also folds verifyCommittedSurfaceBase's open-coded ancestry read onto the shared `probeAncestry` (#5370/#5847), so git DECLINING to answer is no longer read as a verdict of "not an ancestor". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AZgRyPVwi1jLb1mNNuUQ9o
1 parent f1850d8 commit d9b24bb

2 files changed

Lines changed: 460 additions & 30 deletions

File tree

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

Lines changed: 258 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,23 +1516,35 @@ describe('build-schemas.ts — the drift notice names the direction it measured
15161516
//
15171517
// Truncation moves TWO things here, and the second was a surprise worth
15181518
// writing down: `merge-base HEAD origin/main` itself fails once the walk is
1519-
// cut, so `resolveSurfaceBase` falls back to origin/main's TIP as the
1520-
// baseline (it says so — "using origin/main tip … as the baseline anchor").
1521-
// The pair being compared is therefore anchor-at-`tip` vs baseline-at-
1522-
// `mainTip`, not the fork point at all. And the ancestry between them is
1523-
// exactly what a grafted history cannot answer: `mainTip` is its own shallow
1524-
// root, so walking down from it to reach `tip` is the walk that was cut, and
1525-
// the reverse is a plain negative. Neither probe yields a usable answer.
1519+
// cut, so `resolveSurfaceBase` has to anchor the baseline somewhere else.
1520+
// The pair being compared is therefore the committed anchor against THAT
1521+
// baseline, not against the fork point — and the ancestry between them is
1522+
// exactly what a grafted history cannot answer, in either direction.
15261523
//
1527-
// The old line printed "trails the baseline at <mainTip> by 1 key(s)" here,
1524+
// The old line printed "trails the baseline at <rev> by 1 key(s)" here,
15281525
// which happens to be TRUE of the untruncated history — and that is the
15291526
// point: it was never measured, it was assumed, and one fixture over the
15301527
// same assumption printed the exact opposite of the truth. Declining is the
15311528
// same disposition #5370 already took for the write.
1529+
//
1530+
// #6452 re-based this fixture without changing its subject. The baseline a
1531+
// shallow run resolves is no longer origin/main's TIP (that is what made
1532+
// main's additions read as this branch's deletions), so the fixture now
1533+
// says on MAIN which upstream rev the anchor names — `older` — instead of
1534+
// inheriting whichever anchor an earlier case left behind. And BOTH fetched
1535+
// revs are grafted, which is what a checkout that fetched two commits at
1536+
// `--depth=1` actually looks like: with `tip` a shallow root too, neither
1537+
// ancestry probe can answer and the notice must still decline.
1538+
seedSurfaceBase(older, (k) => k.filter((x) => x !== AHEAD_KEY && x !== LANDED_KEY));
1539+
git('add', 'authorable-surface.base.json');
1540+
git('commit', '-q', '-m', 'fixture: main records its anchor at the older baseline');
1541+
const mainHead = git('rev-parse', 'HEAD');
1542+
git('update-ref', 'refs/remotes/origin/main', mainHead);
1543+
15321544
git('checkout', '-q', '-B', 'issue-5847-shallow', older);
15331545
seedSurface((s) => s);
15341546
const anchorAtTip = commitAnchor(tip, (k) => k.filter((x) => x !== LANDED_KEY));
1535-
fs.writeFileSync(shallowFile(), `${mainTip}\n`);
1547+
fs.writeFileSync(shallowFile(), `${mainHead}\n${tip}\n`);
15361548
expect(git('rev-parse', '--is-shallow-repository')).toBe('true');
15371549

15381550
const { status, output } = run([]);
@@ -1542,8 +1554,8 @@ describe('build-schemas.ts — the drift notice names the direction it measured
15421554
expect(git('status', '--porcelain', '-uno')).toBe('');
15431555
expect(output).toContain('differs from the baseline this build resolved');
15441556
expect(output).toContain(
1545-
`${tip.slice(0, 12)}, that baseline is at ${mainTip.slice(0, 12)}, and they differ by ` +
1546-
`1 key(s) only that baseline has`,
1557+
`${tip.slice(0, 12)}, that baseline is at ${older.slice(0, 12)}, and they differ by ` +
1558+
`1 key(s) only the anchor has`,
15471559
);
15481560
expect(output).toContain(
15491561
'shallow checkout — a "not an ancestor" answer is not usable about a truncated history',
@@ -1610,6 +1622,241 @@ describe('build-schemas.ts — the drift notice names the direction it measured
16101622
);
16111623
});
16121624

1625+
// ─────────────────────────────────────────────────────────────────────────────
1626+
// #6452 — a truncated history moves the ANCHOR, never the verdict.
1627+
//
1628+
// `merge-base HEAD origin/main` cannot answer in a shallow checkout, and the old
1629+
// fallback anchored on origin/main's TIP. Under a tip anchor "main added a key
1630+
// after this branch forked" and "this branch deleted a key" are the SAME fact,
1631+
// so the gate reported the first as the second: #6359 measured PR #6356 — which
1632+
// touched no packages/spec file at all — being told it had deleted
1633+
// `ui/BulkActionDef:requiredPermissions`, a key main had just added. Nothing
1634+
// guarded that path (the calling block carries no `if (CHECK)`) and its verdict
1635+
// is `process.exit(1)`, so it is every `gen:schema` in a shallow job, not one
1636+
// gate in one mode.
1637+
//
1638+
// The two obvious dispositions were both refused before this one was chosen:
1639+
// reporting "unverified" instead of adjudicating is the #4650 bypass in every
1640+
// shallow job at once (`resolveSurfaceBase`'s own doc comment says so), and
1641+
// erroring on the CI configuration paints that whole set of jobs red. So the
1642+
// anchor moves and the verdict does not: the gate still runs, and a key that
1643+
// existed at the anchored rev and is gone now is still caught.
1644+
//
1645+
// What these cases pin, and why each one can go red:
1646+
//
1647+
// 1. the false red is gone — same tree, same truncation, main's addition is
1648+
// no longer this branch's deletion;
1649+
// 2. the gate did NOT weaken — a real deletion is still red, and it names the
1650+
// deleted key rather than main's addition;
1651+
// 3. the baseline's keys come from GIT at that commit, never from the anchor
1652+
// FILE. This is the acceptance criterion that cannot be assumed: if the
1653+
// resolution took the file's own `keys`, `verifyCommittedSurfaceBase` would
1654+
// hit its `rev === resolved.rev` fast path and compare the anchor against
1655+
// itself, so a line shed from it would pass. The fixture sheds one;
1656+
// 4. an anchor rev nothing upstream vouches for is not used — a PR can point
1657+
// `baseRev` at one of its OWN commits (a `--depth=1` fetch resolves any sha
1658+
// the remote advertises), and a truncated history cannot refute it by
1659+
// walking, so the rev is accepted only when origin/main's own copy of the
1660+
// anchor names it (or reachability is demonstrated outright);
1661+
// 5. with no upstream anchor at all the run keeps the tip and SAYS so, naming
1662+
// `fetch-depth: 0`. That residual false red is the honest degradation, and
1663+
// it is pinned so it stays loud rather than becoming a silent skip.
1664+
describe('build-schemas.ts — a shallow checkout re-anchors the deletion gate, it does not accuse (#6452)', () => {
1665+
/** Only in the baseline at origin/main's TIP: what main added after this branch forked. */
1666+
const MAIN_ADDED_KEY = 'data/Object:zzAddedOnMainAfterTheFork6452';
1667+
/** In the baseline at the ANCHORED rev too, so its absence is a real deletion. */
1668+
const BRANCH_DELETED_KEY = 'data/Object:zzDeletedByThisBranch6452';
1669+
/** A live key this build really emits — shed from the anchor FILE by case 3. */
1670+
const SHED_KEY = 'data/Object:label';
1671+
1672+
const shallowFile = (): string => path.join(sandbox, '.git', 'shallow');
1673+
1674+
/** `git()` throws on a non-zero exit, which is what the fixture guards expect. */
1675+
const mergeBaseFails = (rev: string): boolean =>
1676+
spawnSync('git', ['merge-base', 'HEAD', rev], { cwd: sandbox }).status !== 0;
1677+
1678+
beforeAll(() => {
1679+
expect(pristineSurface, `${SHED_KEY} is no longer in the baseline — pick another live key`).toContain(
1680+
SHED_KEY,
1681+
);
1682+
});
1683+
1684+
/**
1685+
* The upstream ladder every case forks from: a fork point, main's own anchor
1686+
* committed ON MAIN at it, then a main that moves ahead and ADDS a key.
1687+
*
1688+
* Committing the anchor on main is what makes these fixtures model CI rather
1689+
* than a laboratory: origin/main's copy of that file is the only statement
1690+
* about which rev the anchor names that a PR cannot rewrite, and it is exactly
1691+
* what a `--depth=1` fetch of main still carries.
1692+
*/
1693+
function seedUpstream(baseKeys: (keys: string[]) => string[]): {
1694+
forkBase: string;
1695+
anchored: string;
1696+
mainTip: string;
1697+
} {
1698+
seedManifest((s) => s);
1699+
const forkBase = seedBase(baseKeys);
1700+
seedSurfaceBase(forkBase, baseKeys);
1701+
git('add', 'authorable-surface.base.json');
1702+
git('commit', '-q', '-m', 'fixture: main anchors at the fork point');
1703+
const anchored = git('rev-parse', 'HEAD');
1704+
git('update-ref', 'refs/remotes/origin/main', anchored);
1705+
const mainTip = seedBase((s) => [...baseKeys(s), MAIN_ADDED_KEY].sort());
1706+
return { forkBase, anchored, mainTip };
1707+
}
1708+
1709+
/** Fork at an upstream commit and truncate history the way CI's checkout does. */
1710+
function forkBranch(name: string, at: string, mainTip: string): void {
1711+
git('checkout', '-q', '-B', name, at);
1712+
// The worktree carries what this build emits, so every case below is judged
1713+
// on its baseline rather than on artifact staleness.
1714+
seedSurface((s) => s);
1715+
fs.writeFileSync(shallowFile(), `${mainTip}\n`);
1716+
expect(git('rev-parse', '--is-shallow-repository')).toBe('true');
1717+
expect(mergeBaseFails(mainTip), 'the fixture is not truncated — merge-base still answers').toBe(true);
1718+
}
1719+
1720+
afterEach(() => {
1721+
fs.rmSync(shallowFile(), { force: true });
1722+
git('checkout', '-q', '-f', 'main');
1723+
// Hand `main` back current and CLEAN, so the describes after this one start
1724+
// from a tree with no fixture of ours in it — the surface first, then an
1725+
// anchor that names the commit just made, so what is left behind is authentic
1726+
// by construction rather than by luck (one case here removes main's anchor
1727+
// outright, and the next describe reads whatever this leaves).
1728+
seedSurface((s) => s);
1729+
git('add', AUTHORABLE_SURFACE_DIR_NAME);
1730+
git('commit', '-q', '--allow-empty', '-m', 'fixture: restore the pristine surface on main');
1731+
seedSurfaceBase(git('rev-parse', 'HEAD'), (k) => k);
1732+
git('add', 'authorable-surface.base.json');
1733+
git('commit', '-q', '-m', 'fixture: restore a current anchor on main');
1734+
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
1735+
});
1736+
1737+
it(
1738+
'no false red: a key main added after the fork is not reported as this branch deleting it',
1739+
{ timeout: SPAWN_TIMEOUT_MS },
1740+
() => {
1741+
const { forkBase, anchored, mainTip } = seedUpstream((s) => s);
1742+
forkBranch('issue-6452-no-false-red', anchored, mainTip);
1743+
1744+
const { status, output } = run(['--check']);
1745+
1746+
// The anchor moved, and the line says which rev and on whose authority.
1747+
expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`);
1748+
expect(output).toContain("origin/main's own authorable-surface.base.json names the same commit");
1749+
expect(output).not.toContain('using origin/main tip');
1750+
// The defect itself: under the tip anchor this run exited 1 naming
1751+
// MAIN_ADDED_KEY as an unproven deletion.
1752+
expect(output).not.toContain('deleted without proof');
1753+
expect(output).not.toContain(MAIN_ADDED_KEY);
1754+
expect(status).toBe(0);
1755+
},
1756+
);
1757+
1758+
it(
1759+
'the gate does not weaken: a genuine deletion is still red under the same truncation',
1760+
{ timeout: SPAWN_TIMEOUT_MS },
1761+
() => {
1762+
const { forkBase, anchored, mainTip } = seedUpstream((s) => [...s, BRANCH_DELETED_KEY].sort());
1763+
forkBranch('issue-6452-real-deletion', anchored, mainTip);
1764+
1765+
const { status, output } = run(['--check']);
1766+
1767+
expect(status).toBe(1);
1768+
expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`);
1769+
expect(output).toContain('1 authorable baseline line(s) were deleted without proof (#4650)');
1770+
expect(output).toContain(BRANCH_DELETED_KEY);
1771+
// Exactly one, and the right one: main's addition is not in the verdict.
1772+
expect(output).not.toContain(MAIN_ADDED_KEY);
1773+
},
1774+
);
1775+
1776+
it(
1777+
'the baseline keys come from git at that commit, never from the anchor file (no self-verification)',
1778+
{ timeout: SPAWN_TIMEOUT_MS },
1779+
() => {
1780+
const { forkBase, anchored, mainTip } = seedUpstream((s) => s);
1781+
forkBranch('issue-6452-shed-anchor-key', anchored, mainTip);
1782+
// The anchor sheds a line the commit it NAMES really carries — the #4650
1783+
// attack moved one file over, and the shape the shortcut would bless.
1784+
seedSurfaceBase(forkBase, (k) => k.filter((x) => x !== SHED_KEY));
1785+
1786+
const { status, output } = run(['--check']);
1787+
1788+
// THE pin. Resolve the baseline from the anchor file's own `keys` and this
1789+
// comparison becomes file-against-file: it passes, the run exits 0, and the
1790+
// shed line is gone from the baseline for good. Reading the keys out of git
1791+
// at `baseRev` is the only thing that makes it red.
1792+
expect(status).toBe(1);
1793+
expect(output).toContain('is not the baseline it claims to be (#4650, #5235)');
1794+
expect(output).toContain(`- ${SHED_KEY} (at ${forkBase.slice(0, 12)}, absent here)`);
1795+
},
1796+
);
1797+
1798+
it(
1799+
'an anchor rev nothing upstream vouches for is not used — the rev origin/main records is',
1800+
{ timeout: SPAWN_TIMEOUT_MS },
1801+
() => {
1802+
const { forkBase, anchored, mainTip } = seedUpstream((s) => [...s, BRANCH_DELETED_KEY].sort());
1803+
git('checkout', '-q', '-B', 'issue-6452-unvouched', anchored);
1804+
// The branch deletes the key, commits it, and then points `baseRev` at its
1805+
// OWN commit — authentic against itself (its keys ARE that commit's
1806+
// surface), upstream against nothing. A truncated history cannot refute it
1807+
// by walking, which is why the rev has to be vouched for rather than merely
1808+
// checked.
1809+
seedSurface((s) => s);
1810+
git('add', AUTHORABLE_SURFACE_DIR_NAME);
1811+
git('commit', '-q', '-m', 'fixture: the branch deletes a baseline key');
1812+
const branchOwn = git('rev-parse', 'HEAD');
1813+
seedSurfaceBase(branchOwn, (k) => k);
1814+
git('add', 'authorable-surface.base.json');
1815+
git('commit', '-q', '-m', 'fixture: the branch anchors on its own commit');
1816+
fs.writeFileSync(shallowFile(), `${mainTip}\n`);
1817+
expect(mergeBaseFails(mainTip)).toBe(true);
1818+
1819+
const { status, output } = run(['--check']);
1820+
1821+
expect(output).toContain(`names ${branchOwn.slice(0, 12)}, which nothing here can`);
1822+
expect(output).toContain(`anchors on ${forkBase.slice(0, 12)} rather than on`);
1823+
// …and the deletion the forged anchor was hiding is still adjudicated.
1824+
expect(status).toBe(1);
1825+
expect(output).toContain('deleted without proof (#4650)');
1826+
expect(output).toContain(BRANCH_DELETED_KEY);
1827+
},
1828+
);
1829+
1830+
it(
1831+
'with no upstream anchor at all it keeps the tip and says so, naming fetch-depth: 0',
1832+
{ timeout: SPAWN_TIMEOUT_MS },
1833+
() => {
1834+
// The honest degradation, pinned so it stays LOUD. A main whose tree carries
1835+
// no anchor cannot vouch for anything, so this run has only the tip — and
1836+
// the tip anchor is the defect. It reports the residual false red instead of
1837+
// waiving the check, because a diagnosable false red beats a silent bypass.
1838+
seedManifest((s) => s);
1839+
const forkBase = seedBase((s) => s);
1840+
seedSurfaceBase(forkBase, (k) => k);
1841+
git('add', 'authorable-surface.base.json');
1842+
git('commit', '-q', '-m', 'fixture: anchor at the fork point');
1843+
const anchored = git('rev-parse', 'HEAD');
1844+
git('rm', '-q', 'authorable-surface.base.json');
1845+
const mainTip = seedBase((s) => [...s, MAIN_ADDED_KEY].sort());
1846+
forkBranch('issue-6452-no-upstream-anchor', anchored, mainTip);
1847+
1848+
const { status, output } = run(['--check']);
1849+
1850+
expect(output).toContain(`using origin/main tip ${mainTip.slice(0, 12)} as the baseline anchor`);
1851+
expect(output).toContain('no upstream anchor was usable here');
1852+
expect(output).toContain('fetch-depth: 0');
1853+
expect(status).toBe(1);
1854+
expect(output).toContain('deleted without proof');
1855+
expect(output).toContain(MAIN_ADDED_KEY);
1856+
},
1857+
);
1858+
});
1859+
16131860
// ─────────────────────────────────────────────────────────────────────────────
16141861
// #5371 — the output clean is scoped to THIS generator's artifacts.
16151862
//

0 commit comments

Comments
 (0)