@@ -1516,25 +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 — "no merge base is walkable here, so this run
1521- // anchors on the origin/main TIP …", the line #6359 reworded to name what
1522- // that anchor then MISJUDGES).
1523- // The pair being compared is therefore anchor-at-`tip` vs baseline-at-
1524- // `mainTip`, not the fork point at all. And the ancestry between them is
1525- // exactly what a grafted history cannot answer: `mainTip` is its own shallow
1526- // root, so walking down from it to reach `tip` is the walk that was cut, and
1527- // 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.
15281523 //
1529- // 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,
15301525 // which happens to be TRUE of the untruncated history — and that is the
15311526 // point: it was never measured, it was assumed, and one fixture over the
15321527 // same assumption printed the exact opposite of the truth. Declining is the
15331528 // 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+
15341544 git ( 'checkout' , '-q' , '-B' , 'issue-5847-shallow' , older ) ;
15351545 seedSurface ( ( s ) => s ) ;
15361546 const anchorAtTip = commitAnchor ( tip , ( k ) => k . filter ( ( x ) => x !== LANDED_KEY ) ) ;
1537- fs . writeFileSync ( shallowFile ( ) , `${ mainTip } \n` ) ;
1547+ fs . writeFileSync ( shallowFile ( ) , `${ mainHead } \n ${ tip } \n` ) ;
15381548 expect ( git ( 'rev-parse' , '--is-shallow-repository' ) ) . toBe ( 'true' ) ;
15391549
15401550 const { status, output } = run ( [ ] ) ;
@@ -1544,8 +1554,8 @@ describe('build-schemas.ts — the drift notice names the direction it measured
15441554 expect ( git ( 'status' , '--porcelain' , '-uno' ) ) . toBe ( '' ) ;
15451555 expect ( output ) . toContain ( 'differs from the baseline this build resolved' ) ;
15461556 expect ( output ) . toContain (
1547- `${ tip . slice ( 0 , 12 ) } , that baseline is at ${ mainTip . slice ( 0 , 12 ) } , and they differ by ` +
1548- `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` ,
15491559 ) ;
15501560 expect ( output ) . toContain (
15511561 '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
16121622 ) ;
16131623} ) ;
16141624
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 ( 'no merge base is walkable here' ) ;
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 ( 'no merge base is walkable here, and no upstream anchor was usable' ) ;
1851+ expect ( output ) . toContain ( `anchors on the origin/main TIP ${ mainTip . slice ( 0 , 12 ) } instead` ) ;
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+
16151860// ─────────────────────────────────────────────────────────────────────────────
16161861// #5371 — the output clean is scoped to THIS generator's artifacts.
16171862//
0 commit comments