Skip to content

Commit 82bfb6e

Browse files
os-zhuangclaude
andauthored
fix(spec): 锚点漂移提示按实测方向措辞,不再把「领先」说成 trails … by 0 key(s) (#5847) (#6309)
`gen:schema` 在锚点与本次解析基线不一致时只有一句固定措辞: 「trails the baseline at <rev> by <n> key(s)」,其中 n 算的是 `解析基线的键 ∖ 锚点的键`。只有当锚点是两者中较旧的一方时,这个数才等于差距。 当已提交的锚点更新时,解析基线的键是锚点键的子集,n 恒为 0,整句退化成 「trails the baseline at 9ce056a879ef by 0 key(s)」—— 方向说反,且唯一能反驳它 的那个数字被清零。 方向改为「实测」而不是「假设」:把 #5370 对 `merge-base --is-ancestor` 三值 (外加 shallow 第四读)的解读抽成共享的 `probeAncestry`,再由 `relateAnchorToBaseline` 给出 behind / ahead / unordered。重锚门禁与本提示因此 共用同一个判定 —— 同一个方向存在两套独立判定,正是它们日后各说各话的原因, 而本单就是那次分歧的账单。 三条消息都同时报告两侧的键差,因为任一侧都可能为空,成对出现才有信息量。 `unordered` 不是兜底而是诚实答案:shallow 检出(CI 的 typecheck job 与所有 agent 容器都是)、git 拒绝作答、以及两个 authentic 祖先分处一次 merge 两侧 —— 在这些 情形下声称方向,就是本单要消灭的缺陷换个状态重演。 门禁本身分毫未动:退出码、写入的文件、判定全部保持原样,实测在 behind / ahead 两个状态下 exit code、`git status`、锚点字节、两个 ratchet 目录与整棵 ~1600 个文件的 json-schema/ 产物树逐字节相同。 Fixes #5847 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 465c5fc commit 82bfb6e

2 files changed

Lines changed: 419 additions & 40 deletions

File tree

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

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/by 0 key\(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(/by 0 key\(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(/by 0 key\(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(/by 0 key\(s\)/);
1576+
},
1577+
);
1578+
});
1579+
13291580
// ─────────────────────────────────────────────────────────────────────────────
13301581
// #5371 — the output clean is scoped to THIS generator's artifacts.
13311582
//

0 commit comments

Comments
 (0)