From ad1c90216019aada500fe3db8b8208adc67192f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:31:16 +0000 Subject: [PATCH] =?UTF-8?q?fix(spec):=20=E9=94=9A=E7=82=B9=E6=BC=82?= =?UTF-8?q?=E7=A7=BB=E6=8F=90=E7=A4=BA=E6=8C=89=E5=AE=9E=E6=B5=8B=E6=96=B9?= =?UTF-8?q?=E5=90=91=E6=8E=AA=E8=BE=9E,=E4=B8=8D=E5=86=8D=E6=8A=8A?= =?UTF-8?q?=E3=80=8C=E9=A2=86=E5=85=88=E3=80=8D=E8=AF=B4=E6=88=90=20trails?= =?UTF-8?q?=20=E2=80=A6=20by=200=20key(s)=20(#5847)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gen:schema` 在锚点与本次解析基线不一致时只有一句固定措辞: 「trails the baseline at by 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 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --- .../scripts/build-schemas-check-mode.test.ts | 251 ++++++++++++++++++ packages/spec/scripts/build-schemas.ts | 208 ++++++++++++--- 2 files changed, 419 insertions(+), 40 deletions(-) diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 1ed278bd96..1fd6f692fa 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -1326,6 +1326,257 @@ describe('build-schemas.ts — --update-base moves the anchor forward or not at ); }); +// ───────────────────────────────────────────────────────────────────────────── +// #5847 — the drift notice names the direction it MEASURED. +// +// The anchor and the baseline a build resolves disagree constantly, and the +// notice that reports it used to have one sentence for both directions: +// "trails the baseline at by key(s)", with `n` counted as +// `resolved keys ∖ anchor keys`. That count is only the size of the gap when the +// anchor is the OLDER of the two. When the committed anchor is newer — the +// resolved baseline's keys are then a subset of the anchor's — `n` is 0 and the +// line reads "trails the baseline at 9ce056a879ef by 0 key(s)": the direction +// backwards, and the one number that could have contradicted it zeroed out. +// +// Both states are ordinary. #5370 catalogues the two ways in (a build during an +// uncommitted merge, and a branch that forked before the anchor advanced), and +// since #5370 `--update-base` REFUSES in exactly this state and explains the +// direction correctly — so one situation was being described by two of our own +// messages in contradictory language. Nothing about the gate changes here: same +// exit code, same (absent) writes, same verdicts. Only the sentence. +// +// The direction is decided by the SAME `merge-base --is-ancestor` reading the +// re-anchor guard decides on (`probeAncestry`), never by a second key +// subtraction — a subtraction is what said the wrong thing in the first place, +// and two independent determinations of one direction is how the two answers +// drift apart again. +describe('build-schemas.ts — the drift notice names the direction it measured (#5847)', () => { + /** In `tip` and `mainTip`, absent from `older`: the key the anchor holds and the resolved baseline does not. */ + const AHEAD_KEY = 'data/Object:label'; + /** Only in `mainTip`: what origin/main added after the anchor. */ + const LANDED_KEY = 'data/Object:description'; + /** A key in a DIFFERENT shard, so the unordered fixture's two sides merge without conflict. */ + const UI_KEY = 'ui/View:form'; + + /** The branch's fork point: upstream, and behind the committed anchor. */ + let older: string; + /** Ahead of `older`, on origin/main — what the AHEAD fixture's anchor mirrors. */ + let tip: string; + /** origin/main, ahead of both. */ + let mainTip: string; + + /** `git()` throws on a non-zero exit, which is exactly what a NEGATIVE ancestry + * probe returns — so fixture validation needs its own non-throwing runner. */ + const isAncestor = (a: string, b: string): boolean => + spawnSync('git', ['merge-base', '--is-ancestor', a, b], { cwd: sandbox }).status === 0; + + const shallowFile = (): string => path.join(sandbox, '.git', 'shallow'); + + beforeAll(() => { + for (const k of [AHEAD_KEY, LANDED_KEY, UI_KEY]) { + expect(pristineSurface, `${k} is no longer in the baseline — pick another live key`).toContain(k); + } + }); + + beforeEach(() => { + seedManifest((s) => s); + // Three upstream commits, linear, each one key richer than the last — the + // same ladder #5370 uses, because these are the same two states it named. + older = seedBase((s) => s.filter((k) => k !== AHEAD_KEY && k !== LANDED_KEY)); + tip = seedBase((s) => s.filter((k) => k !== LANDED_KEY)); + mainTip = seedBase((s) => s); + seedSurface((s) => s); + }); + + afterEach(() => { + fs.rmSync(shallowFile(), { force: true }); + git('checkout', '-q', '-f', 'main'); + // Hand `main` back current and CLEAN: an anchor that mirrors main's own tip, + // so the describes after this one start from a tree with no drift of ours in it. + seedSurface((s) => s); + seedSurfaceBase(git('rev-parse', 'HEAD'), (k) => k); + git('add', AUTHORABLE_SURFACE_DIR_NAME, 'authorable-surface.base.json'); + git('commit', '-q', '--allow-empty', '-m', 'fixture: restore a current anchor on main'); + git('update-ref', 'refs/remotes/origin/main', 'HEAD'); + }); + + /** Commit the anchor — and the surface the fork restored alongside it, since a + * `checkout` to an older commit takes the shards back with it — so that + * `git status` staying empty across a run can mean "this run wrote nothing". */ + function commitAnchor(baseRev: string, mutate: (keys: string[]) => string[]): string { + const bytes = seedSurfaceBase(baseRev, mutate); + git('add', AUTHORABLE_SURFACE_DIR_NAME, 'authorable-surface.base.json'); + git('commit', '-q', '-m', `fixture: anchor at ${baseRev.slice(0, 12)}`); + expect(git('status', '--porcelain', '-uno')).toBe(''); + return bytes; + } + + it( + 'says the anchor TRAILS when it is the older of the two, and names both revs and both deltas', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The ordinary lag: HEAD is on main, the anchor mirrors an older upstream + // commit. This direction was never wrong — what it lacked was the anchor's + // own rev (so the reader could see WHICH two commits disagree) and the + // reverse delta. + const anchorAtOlder = commitAnchor(older, (k) => + k.filter((x) => x !== AHEAD_KEY && x !== LANDED_KEY), + ); + expect(isAncestor(older, mainTip)).toBe(true); + + const { status, output } = run([]); + + expect(status).toBe(0); + expect(readSurfaceBase()).toBe(anchorAtOlder); + expect(git('status', '--porcelain', '-uno')).toBe(''); + expect(output).toContain(`trails the baseline at ${mainTip.slice(0, 12)}: it mirrors the older`); + expect(output).toContain(`${older.slice(0, 12)}, and they differ by 2 key(s) only that baseline has`); + expect(output).toContain('not an error'); + expect(output).toContain('gen:authorable-surface-base'); + expect(output).not.toContain('AHEAD of'); + expect(output).not.toMatch(/by 0 key\(s\)/); + expect(output).not.toContain('⚓'); + }, + ); + + it( + 'says the anchor is AHEAD when it is the newer of the two — never "trails … by 0 key(s)"', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // THE regression. The anchor mirrors `tip`; HEAD forked at `older`, so the + // baseline this build resolves is `older` and its keys are a strict SUBSET + // of the anchor's. The old subtraction therefore counted 0 and the line + // claimed the file trailed a baseline it is a descendant of. + git('checkout', '-q', '-B', 'issue-5847-ahead', older); + seedSurface((s) => s); + const anchorAtTip = commitAnchor(tip, (k) => k.filter((x) => x !== LANDED_KEY)); + expect(git('merge-base', 'HEAD', mainTip)).toBe(older); + expect(isAncestor(older, tip)).toBe(true); + expect(isAncestor(tip, older)).toBe(false); + + const { status, output } = run([]); + + // Exit code and files are the half that must NOT move: this is a sentence + // fix, and a diagnostic that starts deciding things is a different change. + expect(status).toBe(0); + expect(readSurfaceBase()).toBe(anchorAtTip); + expect(git('status', '--porcelain', '-uno')).toBe(''); + expect(output).toContain('is AHEAD of the baseline this build resolved'); + expect(output).toContain( + `${tip.slice(0, 12)}, a DESCENDANT of the merge base ${older.slice(0, 12)} that HEAD resolves to`, + ); + expect(output).toContain('they differ by 1 key(s) only the anchor has'); + // The two halves of the defect, pinned as negatives so a future edit cannot + // reintroduce either one without this going red. + expect(output).not.toContain('trails the baseline'); + expect(output).not.toMatch(/by 0 key\(s\)/); + expect(output).not.toContain('⚓'); + }, + ); + + it( + 'claims NO direction in a shallow checkout, and names truncation as the reason', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // CI's own typecheck job is a shallow checkout, and so is every agent + // container — so this is the common environment, not an exotic one. + // + // Truncation moves TWO things here, and the second was a surprise worth + // writing down: `merge-base HEAD origin/main` itself fails once the walk is + // cut, so `resolveSurfaceBase` falls back to origin/main's TIP as the + // baseline (it says so — "using origin/main tip … as the baseline anchor"). + // The pair being compared is therefore anchor-at-`tip` vs baseline-at- + // `mainTip`, not the fork point at all. And the ancestry between them is + // exactly what a grafted history cannot answer: `mainTip` is its own shallow + // root, so walking down from it to reach `tip` is the walk that was cut, and + // the reverse is a plain negative. Neither probe yields a usable answer. + // + // The old line printed "trails the baseline at by 1 key(s)" here, + // which happens to be TRUE of the untruncated history — and that is the + // point: it was never measured, it was assumed, and one fixture over the + // same assumption printed the exact opposite of the truth. Declining is the + // same disposition #5370 already took for the write. + git('checkout', '-q', '-B', 'issue-5847-shallow', older); + seedSurface((s) => s); + const anchorAtTip = commitAnchor(tip, (k) => k.filter((x) => x !== LANDED_KEY)); + fs.writeFileSync(shallowFile(), `${mainTip}\n`); + expect(git('rev-parse', '--is-shallow-repository')).toBe('true'); + + const { status, output } = run([]); + + expect(status).toBe(0); + expect(readSurfaceBase()).toBe(anchorAtTip); + expect(git('status', '--porcelain', '-uno')).toBe(''); + expect(output).toContain('differs from the baseline this build resolved'); + expect(output).toContain( + `${tip.slice(0, 12)}, that baseline is at ${mainTip.slice(0, 12)}, and they differ by ` + + `1 key(s) only that baseline has`, + ); + expect(output).toContain( + 'shallow checkout — a "not an ancestor" answer is not usable about a truncated history', + ); + // A direction nobody could establish is never asserted — in EITHER wording. + expect(output).not.toContain('trails the baseline'); + expect(output).not.toContain('AHEAD of'); + expect(output).not.toMatch(/by 0 key\(s\)/); + }, + ); + + it( + 'claims NO direction when the two revs are genuinely unordered', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // Two authentic origin/main ancestors that sit on opposite sides of a merge: + // each passes every check the gate makes about a single rev, and neither is + // an ancestor of the other. There is no direction to report, so the notice + // reports the delta and says so — the disposition `probeAncestry`'s + // `unknown` gets here, as against the re-anchor guard's fail-closed refusal. + const base = seedBase((s) => s); + + git('checkout', '-q', '-B', 'issue-5847-side-a', base); + seedSurface((s) => s.filter((k) => k !== AHEAD_KEY)); + git('add', AUTHORABLE_SURFACE_DIR_NAME); + git('commit', '-q', '-m', 'fixture: one side of the merge (data shard)'); + const sideA = git('rev-parse', 'HEAD'); + + git('checkout', '-q', '-B', 'issue-5847-side-b', base); + seedSurface((s) => s.filter((k) => k !== UI_KEY)); + git('add', AUTHORABLE_SURFACE_DIR_NAME); + git('commit', '-q', '-m', 'fixture: other side of the merge (ui shard)'); + const sideB = git('rev-parse', 'HEAD'); + + // Conflict-free by construction: the two sides touch different shards. + git('merge', '--no-ff', '-q', '-m', 'fixture: merge the two sides', sideA); + const merged = git('rev-parse', 'HEAD'); + git('update-ref', 'refs/remotes/origin/main', merged); + expect(isAncestor(sideA, merged)).toBe(true); + expect(isAncestor(sideA, sideB)).toBe(false); + expect(isAncestor(sideB, sideA)).toBe(false); + + // HEAD forks on side B; the anchor authentically mirrors side A. + git('checkout', '-q', '-B', 'issue-5847-unordered', sideB); + seedSurface((s) => s); + const anchorAtSideA = commitAnchor(sideA, (k) => k.filter((x) => x !== AHEAD_KEY)); + expect(git('merge-base', 'HEAD', merged)).toBe(sideB); + + const { status, output } = run([]); + + expect(status).toBe(0); + expect(readSurfaceBase()).toBe(anchorAtSideA); + expect(git('status', '--porcelain', '-uno')).toBe(''); + expect(output).toContain('differs from the baseline this build resolved'); + expect(output).toContain( + `${sideA.slice(0, 12)}, that baseline is at ${sideB.slice(0, 12)}, and they differ by ` + + `1 key(s) only that baseline has, 1 only the anchor has`, + ); + expect(output).toContain('neither commit is an ancestor of the other'); + expect(output).not.toContain('trails the baseline'); + expect(output).not.toContain('AHEAD of'); + expect(output).not.toMatch(/by 0 key\(s\)/); + }, + ); +}); + // ───────────────────────────────────────────────────────────────────────────── // #5371 — the output clean is scoped to THIS generator's artifacts. // diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index 639b1f8124..3f233b9eef 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -1277,6 +1277,100 @@ function compareAnchorKeys( process.exit(1); } +/** + * `merge-base --is-ancestor`, read as the THREE answers it gives (#5370). + * + * Extracted so the two places that need this direction — the re-anchor guard + * below and the drift notice further down (#5847) — decide it ONCE, with one + * reading of the exit codes. Two independent determinations of one direction is + * how the two answers drift apart later, and this file already paid for the + * drift: the notice used to infer direction from a key subtraction and printed + * the opposite of what the guard said about the very same pair of revs. + * + * 0 is "ancestor", 1 is "not an ancestor", and anything else (128 with a + * `fatal:`, or `null` from the timeout) is git declining to answer. Folded into + * a `&&`/`||` chain the third collapses into the second and an ERROR becomes a + * verdict — the trap cloud#1116 paid for. + * + * Shallow history makes a FOURTH reading necessary, and it is asymmetric. A + * truncated walk can only ever LOSE reachability, never invent it, so exit 0 is + * proof wherever it appears — while exit 1 in a shallow checkout means nothing at + * all (#5358's own first CI run was failed by exactly that answer, about a commit + * that plainly was an ancestor; the same false 1 is reproducible today in any + * agent container, where the anchor's `baseRev` sits in `.git/shallow` as its own + * grafted root). So shallowness is consulted only to decide whether a NEGATIVE + * counts — never to discard a positive, which would refuse re-anchors that are + * demonstrably fine. + * + * What each caller DOES with `unknown` is the caller's own disposition, and the + * two differ on purpose: the guard fails closed (refuses the write), the notice + * simply declines to name a direction. Neither may turn it into a verdict. + */ +type Ancestry = + | { answer: 'yes' } + | { answer: 'no' } + | { answer: 'unknown'; reason: 'git-declined'; status: number | null; stderr: string } + | { answer: 'unknown'; reason: 'shallow' }; + +function probeAncestry(git: GitRun, ancestor: string, descendant: string): Ancestry { + const probe = git('merge-base', '--is-ancestor', ancestor, descendant); + // Reachability was demonstrated. Truncation cannot fake that, so this is the + // one answer that stands in every checkout, shallow included. + if (probe.status === 0) return { answer: 'yes' }; + if (probe.status !== 1) { + return { + answer: 'unknown', + reason: 'git-declined', + status: probe.status, + stderr: (probe.stderr || '').trim().split('\n')[0] || '(no output)', + }; + } + // A negative, on the other hand, is only meaningful where history is WALKABLE — + // the same truncation `verifyCommittedSurfaceBase` accounts for. There it SKIPS + // a verification, which is safe; for the guard below it would BLESS a write, + // which is not, and for the notice it would print a direction backwards. + if (git('rev-parse', '--is-shallow-repository').stdout.trim() === 'true') { + return { answer: 'unknown', reason: 'shallow' }; + } + return { answer: 'no' }; +} + +/** + * Where the committed anchor sits relative to the baseline THIS build resolved + * (#5847) — the question the drift notice has to answer before it can word + * itself, decided on `probeAncestry` above rather than on a key subtraction. + * + * `unordered` is not a failure and not a fallback: it is the honest answer in a + * shallow checkout (CI's own typecheck job is one), when git declines, and in + * the genuinely unordered case where two authentic origin/main ancestors sit on + * different branches of a merge. Naming a direction there would be exactly the + * defect this exists to remove, one state over. + */ +type AnchorRelation = { kind: 'behind' } | { kind: 'ahead' } | { kind: 'unordered'; why: string }; + +function relateAnchorToBaseline(git: GitRun, committedRev: string, resolvedRev: string): AnchorRelation { + const forward = probeAncestry(git, committedRev, resolvedRev); + if (forward.answer === 'yes') return { kind: 'behind' }; + const backward = probeAncestry(git, resolvedRev, committedRev); + if (backward.answer === 'yes') return { kind: 'ahead' }; + // Only a definitive negative BOTH ways is a real fork; anything else is an + // answer nobody has, and the two are told apart because their remedies differ. + const unusable = forward.answer === 'unknown' ? forward : backward.answer === 'unknown' ? backward : null; + if (!unusable) { + return { + kind: 'unordered', + why: 'neither commit is an ancestor of the other — they sit on different branches of a merge', + }; + } + return { + kind: 'unordered', + why: + unusable.reason === 'shallow' + ? 'shallow checkout — a "not an ancestor" answer is not usable about a truncated history' + : `\`git merge-base --is-ancestor\` did not answer (exit ${unusable.status}): ${unusable.stderr}`, + }; +} + /** * The anchor moves FORWARD, or it does not move (#5370). * @@ -1291,22 +1385,11 @@ function compareAnchorKeys( * comes back, the deletion gate stops seeing it, and the offline consumers of * #5235 get a baseline older than the published one. * - * Three exit codes from `merge-base --is-ancestor`, and they must be read as - * three answers, not two: 0 is "ancestor", 1 is "not an ancestor", and anything - * else (128 with a `fatal:`, or `null` from the timeout) is git declining to - * answer. Folded into a `&&`/`||` chain the third collapses into the second and - * an ERROR becomes a verdict — the trap cloud#1116 paid for. Here it fails - * CLOSED: an ancestry nobody could establish refuses the write. - * - * Shallow history makes a FOURTH reading necessary, and it is asymmetric. A - * truncated walk can only ever LOSE reachability, never invent it, so exit 0 is - * proof wherever it appears — while exit 1 in a shallow checkout means nothing at - * all (#5358's own first CI run was failed by exactly that answer, about a commit - * that plainly was an ancestor; the same false 1 is reproducible today in any - * agent container, where the anchor's `baseRev` sits in `.git/shallow` as its own - * grafted root). So shallowness is consulted only to decide whether a NEGATIVE - * counts — never to discard a positive, which would refuse re-anchors that are - * demonstrably fine. + * The three-plus-one readings of `merge-base --is-ancestor` live in + * `probeAncestry` above, shared with the drift notice (#5847). What is decided + * HERE is the disposition on `unknown`, and it is to fail CLOSED: an ancestry + * nobody could establish refuses the write rather than defaulting to one of the + * two answers. */ function assertAnchorMovesForward(git: GitRun, committedRev: string, resolvedRev: string): void { if (committedRev === resolvedRev) return; @@ -1326,24 +1409,16 @@ function assertAnchorMovesForward(git: GitRun, committedRev: string, resolvedRev process.exit(1); }; - const probe = git('merge-base', '--is-ancestor', committedRev, resolvedRev); - // Reachability was demonstrated. Truncation cannot fake that, so this is the - // one answer that stands in every checkout, shallow included. - if (probe.status === 0) return; - if (probe.status !== 1) { + const probe = probeAncestry(git, committedRev, resolvedRev); + if (probe.answer === 'yes') return; + if (probe.answer === 'unknown') { return refuseIndeterminate( - `\`git merge-base --is-ancestor ${from} ${to}\` did not answer (exit ${probe.status}):\n` + - ` ${(probe.stderr || '').trim().split('\n')[0] || '(no output)'}`, - ); - } - // A negative, on the other hand, is only meaningful where history is WALKABLE — - // the same truncation `verifyCommittedSurfaceBase` accounts for. There it SKIPS - // a verification, which is safe; here it would BLESS a write, which is not. - if (git('rev-parse', '--is-shallow-repository').stdout.trim() === 'true') { - return refuseIndeterminate( - 'This is a shallow checkout: history is truncated, so `merge-base --is-ancestor` reports\n' + - ` "not an ancestor" about commits that plainly are one — ${from} is very likely one of\n` + - ' them (a `--depth=1` fetch grafts it in as its own root, unreachable from origin/main).', + probe.reason === 'git-declined' + ? `\`git merge-base --is-ancestor ${from} ${to}\` did not answer (exit ${probe.status}):\n` + + ` ${probe.stderr}` + : 'This is a shallow checkout: history is truncated, so `merge-base --is-ancestor` reports\n' + + ` "not an ancestor" about commits that plainly are one — ${from} is very likely one of\n` + + ' them (a `--depth=1` fetch grafts it in as its own root, unreachable from origin/main).', ); } console.error( @@ -1818,14 +1893,67 @@ function checkManifestRemovals(git: GitRun, baseRev: string | null): void { } else if (drifted) { // Reported, never fatal, and never repaired here: see the notes above on // `main`'s own merge base and on #5358. + // + // The DIRECTION is asked, not assumed (#5847). This used to print one fixed + // sentence — "trails the baseline at by key(s)" — with `n` counted + // as `resolved keys ∖ anchor keys`. In the state where the committed anchor + // is NEWER than the baseline this build resolved, the resolved keys are a + // subset of the anchor's, so that count is 0 and the line degrades to + // "trails the baseline at by 0 key(s)": the direction backwards, and + // the one number that could have contradicted it zeroed out. That state is + // ordinary, not a corner — a build during an uncommitted merge, or a branch + // that forked before the anchor advanced and then took a newer anchor + // (#5370 catalogues both) — and since #5370 `--update-base` REFUSES there + // and explains the direction correctly, so the two were describing one + // situation in contradictory language. + // + // Both key deltas are reported now, because either can be the empty one and + // the pair is what makes the sentence say something. Only the wording and + // the counts change here: this arm still writes nothing, exits nothing, and + // decides nothing. const recorded = new Set(committed.doc.keys); - const behind = anchor.keys.filter((k) => !recorded.has(k)).length; - console.log( - `ℹ️ ${SURFACE_BASE_FILE_NAME} trails the baseline at ${anchor.rev.slice(0, 12)} by ${behind} key(s)\n` + - ` — expected, and not an error: the anchor is a snapshot of an upstream commit, proved\n` + - ` AUTHENTIC rather than current. Re-anchoring is a deliberate act with its own reviewed\n` + - ` diff — \`${REANCHOR_COMMAND}\` — never a side effect of this build (#5358).`, - ); + const resolvedKeys = new Set(anchor.keys); + const onlyBaseline = anchor.keys.filter((k) => !recorded.has(k)).length; + const onlyAnchor = committed.doc.keys.filter((k) => !resolvedKeys.has(k)).length; + const anchorShort = committed.doc.baseRev.slice(0, 12); + const baseShort = anchor.rev.slice(0, 12); + const delta = + onlyBaseline > 0 && onlyAnchor > 0 + ? `${onlyBaseline} key(s) only that baseline has, ${onlyAnchor} only the anchor has` + : onlyBaseline > 0 + ? `${onlyBaseline} key(s) only that baseline has` + : onlyAnchor > 0 + ? `${onlyAnchor} key(s) only the anchor has` + : 'the same keys in a different order'; + const reanchor = + ` Re-anchoring is a deliberate act with its own reviewed diff — \`${REANCHOR_COMMAND}\`\n` + + ` — never a side effect of this build (#5358).`; + const relation = relateAnchorToBaseline(gitInPackage, committed.doc.baseRev, anchor.rev); + if (relation.kind === 'behind') { + console.log( + `ℹ️ ${SURFACE_BASE_FILE_NAME} trails the baseline at ${baseShort}: it mirrors the older\n` + + ` ${anchorShort}, and they differ by ${delta}\n` + + ` — expected, and not an error: the anchor is a snapshot of an upstream commit, proved\n` + + ` AUTHENTIC rather than current.\n${reanchor}`, + ); + } else if (relation.kind === 'ahead') { + console.log( + `ℹ️ ${SURFACE_BASE_FILE_NAME} is AHEAD of the baseline this build resolved: it mirrors\n` + + ` ${anchorShort}, a DESCENDANT of the merge base ${baseShort} that HEAD resolves to, and\n` + + ` they differ by ${delta}\n` + + ` — not an error, and not something to re-anchor: the anchor only ever moves forward, so\n` + + ` what closes this gap is bringing HEAD up to date with origin/main (and committing it),\n` + + ` never a re-anchor onto the older baseline.\n${reanchor}`, + ); + } else { + console.log( + `ℹ️ ${SURFACE_BASE_FILE_NAME} differs from the baseline this build resolved: it mirrors\n` + + ` ${anchorShort}, that baseline is at ${baseShort}, and they differ by ${delta}\n` + + ` — which of the two is newer could not be established here, so this run names no\n` + + ` direction: ${relation.why}.\n` + + ` Not an error either way.\n${reanchor}`, + ); + } } } }