@@ -1326,6 +1326,257 @@ describe('build-schemas.ts — --update-base moves the anchor forward or not at
13261326 ) ;
13271327} ) ;
13281328
1329+ // ─────────────────────────────────────────────────────────────────────────────
1330+ // #5847 — the drift notice names the direction it MEASURED.
1331+ //
1332+ // The anchor and the baseline a build resolves disagree constantly, and the
1333+ // notice that reports it used to have one sentence for both directions:
1334+ // "trails the baseline at <rev> by <n> key(s)", with `n` counted as
1335+ // `resolved keys ∖ anchor keys`. That count is only the size of the gap when the
1336+ // anchor is the OLDER of the two. When the committed anchor is newer — the
1337+ // resolved baseline's keys are then a subset of the anchor's — `n` is 0 and the
1338+ // line reads "trails the baseline at 9ce056a879ef by 0 key(s)": the direction
1339+ // backwards, and the one number that could have contradicted it zeroed out.
1340+ //
1341+ // Both states are ordinary. #5370 catalogues the two ways in (a build during an
1342+ // uncommitted merge, and a branch that forked before the anchor advanced), and
1343+ // since #5370 `--update-base` REFUSES in exactly this state and explains the
1344+ // direction correctly — so one situation was being described by two of our own
1345+ // messages in contradictory language. Nothing about the gate changes here: same
1346+ // exit code, same (absent) writes, same verdicts. Only the sentence.
1347+ //
1348+ // The direction is decided by the SAME `merge-base --is-ancestor` reading the
1349+ // re-anchor guard decides on (`probeAncestry`), never by a second key
1350+ // subtraction — a subtraction is what said the wrong thing in the first place,
1351+ // and two independent determinations of one direction is how the two answers
1352+ // drift apart again.
1353+ describe ( 'build-schemas.ts — the drift notice names the direction it measured (#5847)' , ( ) => {
1354+ /** In `tip` and `mainTip`, absent from `older`: the key the anchor holds and the resolved baseline does not. */
1355+ const AHEAD_KEY = 'data/Object:label' ;
1356+ /** Only in `mainTip`: what origin/main added after the anchor. */
1357+ const LANDED_KEY = 'data/Object:description' ;
1358+ /** A key in a DIFFERENT shard, so the unordered fixture's two sides merge without conflict. */
1359+ const UI_KEY = 'ui/View:form' ;
1360+
1361+ /** The branch's fork point: upstream, and behind the committed anchor. */
1362+ let older : string ;
1363+ /** Ahead of `older`, on origin/main — what the AHEAD fixture's anchor mirrors. */
1364+ let tip : string ;
1365+ /** origin/main, ahead of both. */
1366+ let mainTip : string ;
1367+
1368+ /** `git()` throws on a non-zero exit, which is exactly what a NEGATIVE ancestry
1369+ * probe returns — so fixture validation needs its own non-throwing runner. */
1370+ const isAncestor = ( a : string , b : string ) : boolean =>
1371+ spawnSync ( 'git' , [ 'merge-base' , '--is-ancestor' , a , b ] , { cwd : sandbox } ) . status === 0 ;
1372+
1373+ const shallowFile = ( ) : string => path . join ( sandbox , '.git' , 'shallow' ) ;
1374+
1375+ beforeAll ( ( ) => {
1376+ for ( const k of [ AHEAD_KEY , LANDED_KEY , UI_KEY ] ) {
1377+ expect ( pristineSurface , `${ k } is no longer in the baseline — pick another live key` ) . toContain ( k ) ;
1378+ }
1379+ } ) ;
1380+
1381+ beforeEach ( ( ) => {
1382+ seedManifest ( ( s ) => s ) ;
1383+ // Three upstream commits, linear, each one key richer than the last — the
1384+ // same ladder #5370 uses, because these are the same two states it named.
1385+ older = seedBase ( ( s ) => s . filter ( ( k ) => k !== AHEAD_KEY && k !== LANDED_KEY ) ) ;
1386+ tip = seedBase ( ( s ) => s . filter ( ( k ) => k !== LANDED_KEY ) ) ;
1387+ mainTip = seedBase ( ( s ) => s ) ;
1388+ seedSurface ( ( s ) => s ) ;
1389+ } ) ;
1390+
1391+ afterEach ( ( ) => {
1392+ fs . rmSync ( shallowFile ( ) , { force : true } ) ;
1393+ git ( 'checkout' , '-q' , '-f' , 'main' ) ;
1394+ // Hand `main` back current and CLEAN: an anchor that mirrors main's own tip,
1395+ // so the describes after this one start from a tree with no drift of ours in it.
1396+ seedSurface ( ( s ) => s ) ;
1397+ seedSurfaceBase ( git ( 'rev-parse' , 'HEAD' ) , ( k ) => k ) ;
1398+ git ( 'add' , AUTHORABLE_SURFACE_DIR_NAME , 'authorable-surface.base.json' ) ;
1399+ git ( 'commit' , '-q' , '--allow-empty' , '-m' , 'fixture: restore a current anchor on main' ) ;
1400+ git ( 'update-ref' , 'refs/remotes/origin/main' , 'HEAD' ) ;
1401+ } ) ;
1402+
1403+ /** Commit the anchor — and the surface the fork restored alongside it, since a
1404+ * `checkout` to an older commit takes the shards back with it — so that
1405+ * `git status` staying empty across a run can mean "this run wrote nothing". */
1406+ function commitAnchor ( baseRev : string , mutate : ( keys : string [ ] ) => string [ ] ) : string {
1407+ const bytes = seedSurfaceBase ( baseRev , mutate ) ;
1408+ git ( 'add' , AUTHORABLE_SURFACE_DIR_NAME , 'authorable-surface.base.json' ) ;
1409+ git ( 'commit' , '-q' , '-m' , `fixture: anchor at ${ baseRev . slice ( 0 , 12 ) } ` ) ;
1410+ expect ( git ( 'status' , '--porcelain' , '-uno' ) ) . toBe ( '' ) ;
1411+ return bytes ;
1412+ }
1413+
1414+ it (
1415+ 'says the anchor TRAILS when it is the older of the two, and names both revs and both deltas' ,
1416+ { timeout : SPAWN_TIMEOUT_MS } ,
1417+ ( ) => {
1418+ // The ordinary lag: HEAD is on main, the anchor mirrors an older upstream
1419+ // commit. This direction was never wrong — what it lacked was the anchor's
1420+ // own rev (so the reader could see WHICH two commits disagree) and the
1421+ // reverse delta.
1422+ const anchorAtOlder = commitAnchor ( older , ( k ) =>
1423+ k . filter ( ( x ) => x !== AHEAD_KEY && x !== LANDED_KEY ) ,
1424+ ) ;
1425+ expect ( isAncestor ( older , mainTip ) ) . toBe ( true ) ;
1426+
1427+ const { status, output } = run ( [ ] ) ;
1428+
1429+ expect ( status ) . toBe ( 0 ) ;
1430+ expect ( readSurfaceBase ( ) ) . toBe ( anchorAtOlder ) ;
1431+ expect ( git ( 'status' , '--porcelain' , '-uno' ) ) . toBe ( '' ) ;
1432+ expect ( output ) . toContain ( `trails the baseline at ${ mainTip . slice ( 0 , 12 ) } : it mirrors the older` ) ;
1433+ expect ( output ) . toContain ( `${ older . slice ( 0 , 12 ) } , and they differ by 2 key(s) only that baseline has` ) ;
1434+ expect ( output ) . toContain ( 'not an error' ) ;
1435+ expect ( output ) . toContain ( 'gen:authorable-surface-base' ) ;
1436+ expect ( output ) . not . toContain ( 'AHEAD of' ) ;
1437+ expect ( output ) . not . toMatch ( / b y 0 k e y \( s \) / ) ;
1438+ expect ( output ) . not . toContain ( '⚓' ) ;
1439+ } ,
1440+ ) ;
1441+
1442+ it (
1443+ 'says the anchor is AHEAD when it is the newer of the two — never "trails … by 0 key(s)"' ,
1444+ { timeout : SPAWN_TIMEOUT_MS } ,
1445+ ( ) => {
1446+ // THE regression. The anchor mirrors `tip`; HEAD forked at `older`, so the
1447+ // baseline this build resolves is `older` and its keys are a strict SUBSET
1448+ // of the anchor's. The old subtraction therefore counted 0 and the line
1449+ // claimed the file trailed a baseline it is a descendant of.
1450+ git ( 'checkout' , '-q' , '-B' , 'issue-5847-ahead' , older ) ;
1451+ seedSurface ( ( s ) => s ) ;
1452+ const anchorAtTip = commitAnchor ( tip , ( k ) => k . filter ( ( x ) => x !== LANDED_KEY ) ) ;
1453+ expect ( git ( 'merge-base' , 'HEAD' , mainTip ) ) . toBe ( older ) ;
1454+ expect ( isAncestor ( older , tip ) ) . toBe ( true ) ;
1455+ expect ( isAncestor ( tip , older ) ) . toBe ( false ) ;
1456+
1457+ const { status, output } = run ( [ ] ) ;
1458+
1459+ // Exit code and files are the half that must NOT move: this is a sentence
1460+ // fix, and a diagnostic that starts deciding things is a different change.
1461+ expect ( status ) . toBe ( 0 ) ;
1462+ expect ( readSurfaceBase ( ) ) . toBe ( anchorAtTip ) ;
1463+ expect ( git ( 'status' , '--porcelain' , '-uno' ) ) . toBe ( '' ) ;
1464+ expect ( output ) . toContain ( 'is AHEAD of the baseline this build resolved' ) ;
1465+ expect ( output ) . toContain (
1466+ `${ tip . slice ( 0 , 12 ) } , a DESCENDANT of the merge base ${ older . slice ( 0 , 12 ) } that HEAD resolves to` ,
1467+ ) ;
1468+ expect ( output ) . toContain ( 'they differ by 1 key(s) only the anchor has' ) ;
1469+ // The two halves of the defect, pinned as negatives so a future edit cannot
1470+ // reintroduce either one without this going red.
1471+ expect ( output ) . not . toContain ( 'trails the baseline' ) ;
1472+ expect ( output ) . not . toMatch ( / b y 0 k e y \( s \) / ) ;
1473+ expect ( output ) . not . toContain ( '⚓' ) ;
1474+ } ,
1475+ ) ;
1476+
1477+ it (
1478+ 'claims NO direction in a shallow checkout, and names truncation as the reason' ,
1479+ { timeout : SPAWN_TIMEOUT_MS } ,
1480+ ( ) => {
1481+ // CI's own typecheck job is a shallow checkout, and so is every agent
1482+ // container — so this is the common environment, not an exotic one.
1483+ //
1484+ // Truncation moves TWO things here, and the second was a surprise worth
1485+ // writing down: `merge-base HEAD origin/main` itself fails once the walk is
1486+ // cut, so `resolveSurfaceBase` falls back to origin/main's TIP as the
1487+ // baseline (it says so — "using origin/main tip … as the baseline anchor").
1488+ // The pair being compared is therefore anchor-at-`tip` vs baseline-at-
1489+ // `mainTip`, not the fork point at all. And the ancestry between them is
1490+ // exactly what a grafted history cannot answer: `mainTip` is its own shallow
1491+ // root, so walking down from it to reach `tip` is the walk that was cut, and
1492+ // the reverse is a plain negative. Neither probe yields a usable answer.
1493+ //
1494+ // The old line printed "trails the baseline at <mainTip> by 1 key(s)" here,
1495+ // which happens to be TRUE of the untruncated history — and that is the
1496+ // point: it was never measured, it was assumed, and one fixture over the
1497+ // same assumption printed the exact opposite of the truth. Declining is the
1498+ // same disposition #5370 already took for the write.
1499+ git ( 'checkout' , '-q' , '-B' , 'issue-5847-shallow' , older ) ;
1500+ seedSurface ( ( s ) => s ) ;
1501+ const anchorAtTip = commitAnchor ( tip , ( k ) => k . filter ( ( x ) => x !== LANDED_KEY ) ) ;
1502+ fs . writeFileSync ( shallowFile ( ) , `${ mainTip } \n` ) ;
1503+ expect ( git ( 'rev-parse' , '--is-shallow-repository' ) ) . toBe ( 'true' ) ;
1504+
1505+ const { status, output } = run ( [ ] ) ;
1506+
1507+ expect ( status ) . toBe ( 0 ) ;
1508+ expect ( readSurfaceBase ( ) ) . toBe ( anchorAtTip ) ;
1509+ expect ( git ( 'status' , '--porcelain' , '-uno' ) ) . toBe ( '' ) ;
1510+ expect ( output ) . toContain ( 'differs from the baseline this build resolved' ) ;
1511+ expect ( output ) . toContain (
1512+ `${ tip . slice ( 0 , 12 ) } , that baseline is at ${ mainTip . slice ( 0 , 12 ) } , and they differ by ` +
1513+ `1 key(s) only that baseline has` ,
1514+ ) ;
1515+ expect ( output ) . toContain (
1516+ 'shallow checkout — a "not an ancestor" answer is not usable about a truncated history' ,
1517+ ) ;
1518+ // A direction nobody could establish is never asserted — in EITHER wording.
1519+ expect ( output ) . not . toContain ( 'trails the baseline' ) ;
1520+ expect ( output ) . not . toContain ( 'AHEAD of' ) ;
1521+ expect ( output ) . not . toMatch ( / b y 0 k e y \( s \) / ) ;
1522+ } ,
1523+ ) ;
1524+
1525+ it (
1526+ 'claims NO direction when the two revs are genuinely unordered' ,
1527+ { timeout : SPAWN_TIMEOUT_MS } ,
1528+ ( ) => {
1529+ // Two authentic origin/main ancestors that sit on opposite sides of a merge:
1530+ // each passes every check the gate makes about a single rev, and neither is
1531+ // an ancestor of the other. There is no direction to report, so the notice
1532+ // reports the delta and says so — the disposition `probeAncestry`'s
1533+ // `unknown` gets here, as against the re-anchor guard's fail-closed refusal.
1534+ const base = seedBase ( ( s ) => s ) ;
1535+
1536+ git ( 'checkout' , '-q' , '-B' , 'issue-5847-side-a' , base ) ;
1537+ seedSurface ( ( s ) => s . filter ( ( k ) => k !== AHEAD_KEY ) ) ;
1538+ git ( 'add' , AUTHORABLE_SURFACE_DIR_NAME ) ;
1539+ git ( 'commit' , '-q' , '-m' , 'fixture: one side of the merge (data shard)' ) ;
1540+ const sideA = git ( 'rev-parse' , 'HEAD' ) ;
1541+
1542+ git ( 'checkout' , '-q' , '-B' , 'issue-5847-side-b' , base ) ;
1543+ seedSurface ( ( s ) => s . filter ( ( k ) => k !== UI_KEY ) ) ;
1544+ git ( 'add' , AUTHORABLE_SURFACE_DIR_NAME ) ;
1545+ git ( 'commit' , '-q' , '-m' , 'fixture: other side of the merge (ui shard)' ) ;
1546+ const sideB = git ( 'rev-parse' , 'HEAD' ) ;
1547+
1548+ // Conflict-free by construction: the two sides touch different shards.
1549+ git ( 'merge' , '--no-ff' , '-q' , '-m' , 'fixture: merge the two sides' , sideA ) ;
1550+ const merged = git ( 'rev-parse' , 'HEAD' ) ;
1551+ git ( 'update-ref' , 'refs/remotes/origin/main' , merged ) ;
1552+ expect ( isAncestor ( sideA , merged ) ) . toBe ( true ) ;
1553+ expect ( isAncestor ( sideA , sideB ) ) . toBe ( false ) ;
1554+ expect ( isAncestor ( sideB , sideA ) ) . toBe ( false ) ;
1555+
1556+ // HEAD forks on side B; the anchor authentically mirrors side A.
1557+ git ( 'checkout' , '-q' , '-B' , 'issue-5847-unordered' , sideB ) ;
1558+ seedSurface ( ( s ) => s ) ;
1559+ const anchorAtSideA = commitAnchor ( sideA , ( k ) => k . filter ( ( x ) => x !== AHEAD_KEY ) ) ;
1560+ expect ( git ( 'merge-base' , 'HEAD' , merged ) ) . toBe ( sideB ) ;
1561+
1562+ const { status, output } = run ( [ ] ) ;
1563+
1564+ expect ( status ) . toBe ( 0 ) ;
1565+ expect ( readSurfaceBase ( ) ) . toBe ( anchorAtSideA ) ;
1566+ expect ( git ( 'status' , '--porcelain' , '-uno' ) ) . toBe ( '' ) ;
1567+ expect ( output ) . toContain ( 'differs from the baseline this build resolved' ) ;
1568+ expect ( output ) . toContain (
1569+ `${ sideA . slice ( 0 , 12 ) } , that baseline is at ${ sideB . slice ( 0 , 12 ) } , and they differ by ` +
1570+ `1 key(s) only that baseline has, 1 only the anchor has` ,
1571+ ) ;
1572+ expect ( output ) . toContain ( 'neither commit is an ancestor of the other' ) ;
1573+ expect ( output ) . not . toContain ( 'trails the baseline' ) ;
1574+ expect ( output ) . not . toContain ( 'AHEAD of' ) ;
1575+ expect ( output ) . not . toMatch ( / b y 0 k e y \( s \) / ) ;
1576+ } ,
1577+ ) ;
1578+ } ) ;
1579+
13291580// ─────────────────────────────────────────────────────────────────────────────
13301581// #5371 — the output clean is scoped to THIS generator's artifacts.
13311582//
0 commit comments