From 8f7aabb4efa497fcad9412bb1fc9cd6e5c0c4a8d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 12:34:27 +0000 Subject: [PATCH] =?UTF-8?q?test(cli,create-plugin):=20=E4=B8=A4=E5=A4=84?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=99=A8=E9=94=9A=E8=A7=84=E5=88=99=E6=94=B9?= =?UTF-8?q?=E7=B4=AF=E7=A7=AF=E6=8A=A5=E5=85=A8=E9=83=A8=E6=BC=82=E7=A7=BB?= =?UTF-8?q?,=E5=89=8D=E7=BD=AE=E4=B8=8E=E6=BC=82=E7=A7=BB=E5=88=86?= =?UTF-8?q?=E5=BC=80=20(#4974)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/cli` 的 `sources every range from this repo instead of inventing one` 与 `packages/create-plugin` 的 `sources every devDependency range from this repo instead of inventing them` 都在 `for` 循环里逐名 `expect`。`expect` 失败即抛,所以一次只报第一个 不匹配的名字,而表是插入序遍历的 —— 被报的是「谁排在前面」,不是「谁更严重」。 代价兑现过两次:#4098 有五个同窗 bump 藏在第一个后面;#4968 里 `lucide-react` 排在 `postcss`/`react`/`tailwindcss`/`typescript`/`vite` 之前,遮住后面六个名字的判定, 只能另写脚本才敢说这批到底漂了几处。跨包那半更贵:create-plugin 那处当时在 `main` 上也是红的,但按包边界扫 cli 时没有任何东西报它,落地后仍要第二轮。 两处都改成两趟: - 前置断言(锚必须能解析:名字至少被一个生成器声明、`root` 锚在根清单里、仓内区间 必须一致)保持首败即抛,并且整趟跑在任何区间比对之前 —— 前置败时不产出任何漂移行, 不与漂移串扰。锚不可解析时也没有可累积的期望值。 - 漂移断言累积后一次报空集,逐条带生成器、名字、实得区间与应得区间。 顺带把两张锚表的文档块补上「范围规则只判本表内的名字、完备性规则要求生成清单的键集 恰好等于本表」这一对互补关系 —— 两条规则分居两个 `it`,读任一处都看不到另一处。 Co-authored-by: Claude --- .../anchor-drift-cumulative-report-4974.md | 12 +++ .../cli/src/__tests__/app-generator.test.ts | 94 +++++++++++++------ .../src/__tests__/templates.test.ts | 37 +++++++- 3 files changed, 114 insertions(+), 29 deletions(-) create mode 100644 .changeset/anchor-drift-cumulative-report-4974.md diff --git a/.changeset/anchor-drift-cumulative-report-4974.md b/.changeset/anchor-drift-cumulative-report-4974.md new file mode 100644 index 0000000000..6fe43e103f --- /dev/null +++ b/.changeset/anchor-drift-cumulative-report-4974.md @@ -0,0 +1,12 @@ +--- +--- + +Test-only: the two generator anchor rules — `packages/cli`'s app/init manifests and +`packages/create-plugin`'s template devDependencies — now collect every drifted range and +report them from a single assertion, instead of one `expect` per name that threw on the +first mismatch. A dependabot batch that moves several in-repo ranges is one round of +repair rather than one round per name, and which name got reported no longer depends on +its position in the anchor table. The preconditions (the anchor must resolve; in-repo +manifests must agree) still fail fast and are kept in a separate pass, so a repo-state +failure is never reported as, or beside, template drift. No published behaviour changes +(objectui#4974). diff --git a/packages/cli/src/__tests__/app-generator.test.ts b/packages/cli/src/__tests__/app-generator.test.ts index b435e54c28..10dfc2a7c4 100644 --- a/packages/cli/src/__tests__/app-generator.test.ts +++ b/packages/cli/src/__tests__/app-generator.test.ts @@ -332,6 +332,16 @@ function inRepoRangesOf(name: string): Record { * objectui#3852 migrated that pipeline — so Tailwind anchors to this repo like * everything else. See `keeps the generated Tailwind pipeline v4 end to end` * below for what replaced the ledger. + * + * TWO RULES READ THIS MAP, and they are complements — stated together here + * because they live in separate `it`s below, where neither is visible from the + * other (objectui#4974): the range rule judges ONLY the names listed here, and + * the completeness rule requires the union of the three generated dependency + * maps to equal this key set exactly. So a dependency added to any generator + * without an entry here is not silently unjudged — it fails `keeps all three + * generated dependency maps under one anchor table`, and entering it here is + * what puts its range under the anchor gate at all. Add the dependency and its + * anchor in the same change. */ const DEPENDENCY_ANCHORS: Record = { '@object-ui/components': 'cli-version', @@ -536,44 +546,74 @@ describe('generated app manifests', () => { const plain = allRangesOf(buildAppPackageJson(STANDALONE)); const init = allRangesOf(buildInitPackageJson('sample-app')); const cliVersion = readManifest(CLI_MANIFEST_PATH).version as string; + /** + * Every manifest that declares the name, so all of them are judged rather + * than just the first — one generator anchored and another fossilised is + * exactly the state objectui#3892 found, and reading `routed ?? plain ?? + * init` would have reported it green. + */ + const declaredBy = (name: string) => ({ + routed: routed[name], + plain: plain[name], + init: init[name] + }); + // Pass 1 — the PRECONDITIONS, which are facts about THIS REPO rather than + // drift in a generated range: a name no generator declares at all, a `root` + // anchor the root manifest no longer carries, an in-repo split with no + // single range to quote. They keep throwing on the first failure, and they + // all run before any range is compared, so a broken precondition can never + // be reported as drift nor read as crosstalk beside one (objectui#4974). + // There is nothing to accumulate here either: with the anchor unresolvable + // there is no expectation to compare a generated range against. + const expectedRanges: Record = {}; for (const [name, anchor] of Object.entries(DEPENDENCY_ANCHORS)) { - // Every manifest that declares the name is judged, not just the first — - // one generator anchored and another fossilised is exactly the state - // objectui#3892 found, and reading `routed ?? plain ?? init` would have - // reported it green. - const declaredBy = { routed: routed[name], plain: plain[name], init: init[name] }; expect( - Object.values(declaredBy).some((range) => range !== undefined), + Object.values(declaredBy(name)).some((range) => range !== undefined), `${name} must be declared by at least one generator` ).toBe(true); - for (const [generator, generated] of Object.entries(declaredBy)) { - if (generated === undefined) continue; - const where = `${generator} manifest's ${name}`; + if (anchor === 'cli-version') { + expectedRanges[name] = { range: `^${cliVersion}`, source: "this CLI's own version" }; + continue; + } - if (anchor === 'cli-version') { - expect(generated, `${where} must track this CLI's own version`).toBe(`^${cliVersion}`); - continue; - } + if (anchor === 'root') { + const rootRange = rootRangeOf(name); + expect(rootRange, `${name} must exist in the root manifest`).toBeTruthy(); + expectedRanges[name] = { range: rootRange as string, source: 'the repo root' }; + continue; + } - if (anchor === 'root') { - const rootRange = rootRangeOf(name); - expect(rootRange, `${name} must exist in the root manifest`).toBeTruthy(); - expect(generated, `${where} must match the repo root`).toBe(rootRange); - continue; - } + const byRange = inRepoRangesOf(name); + const ranges = Object.keys(byRange); + expect(ranges.length, `${name} must be declared in-repo to anchor to`).toBeGreaterThan(0); + expect( + ranges.sort(), + `in-repo manifests disagree on ${name}: ${JSON.stringify(byRange)} — settle on one range first` + ).toHaveLength(1); + expectedRanges[name] = { range: ranges[0], source: 'its in-repo range' }; + } - const byRange = inRepoRangesOf(name); - const ranges = Object.keys(byRange); - expect(ranges.length, `${name} must be declared in-repo to anchor to`).toBeGreaterThan(0); - expect( - ranges.sort(), - `in-repo manifests disagree on ${name}: ${JSON.stringify(byRange)} — settle on one range first` - ).toHaveLength(1); - expect(generated, `${where} must match its in-repo range`).toBe(ranges[0]); + // Pass 2 — the DRIFT, accumulated and reported by a single assertion, which + // is what objectui#4974 is about. The per-name `expect` this replaced threw + // on the first mismatch, and the table is walked in insertion order, so + // which drift you were told about depended on a name's POSITION in the + // table rather than on anything about the defect: objectui#4098 had five + // bumps hidden behind the first, and objectui#4968 had six hidden behind + // `lucide-react` — measuring the real size of that batch needed a throwaway + // script, because the gate would only ever name one. One dependabot round + // moving several ranges is now one round of repair, and every line carries + // the generator, the name, the generated range and the range it must be, so + // the whole batch is fixable from one failure report. + const drifted: string[] = []; + for (const [name, { range, source }] of Object.entries(expectedRanges)) { + for (const [generator, generated] of Object.entries(declaredBy(name))) { + if (generated === undefined || generated === range) continue; + drifted.push(`${generator} manifest's ${name}: ${generated} must match ${source}, ${range}`); } } + expect(drifted).toEqual([]); }); it('names every range the pre-fix init manifest had drifted on', () => { diff --git a/packages/create-plugin/src/__tests__/templates.test.ts b/packages/create-plugin/src/__tests__/templates.test.ts index 485ade5314..8fc77117c2 100644 --- a/packages/create-plugin/src/__tests__/templates.test.ts +++ b/packages/create-plugin/src/__tests__/templates.test.ts @@ -129,6 +129,14 @@ function inRepoPluginManifests(): Record { * Every key of the generated `devDependencies` must appear here — the * completeness test below fails on an unanchored addition, so this map cannot * quietly go back to covering a subset (objectui#3742). + * + * That completeness rule and the range rule are complements, stated together + * here because they live in separate `it`s below where neither is visible from + * the other (objectui#4974): the range rule judges ONLY the names listed here, + * so entering the table is what puts a new template devDependency under the + * anchor gate at all, and `anchors every devDependency range, leaving none + * unpinned` is what makes skipping the entry impossible rather than silent. Add + * the devDependency and its anchor in the same change. */ const DEV_DEPENDENCY_ANCHORS: Record = { '@testing-library/jest-dom': 'root', @@ -349,11 +357,20 @@ describe('generated package.json', () => { // this test exists to catch — update `src/templates.ts` in the same PR. const generated = generatedDevDependencies(VARS); + // Pass 1 — the PRECONDITIONS, which are facts about THIS REPO rather than + // drift in a generated range: a `root` anchor the root manifest no longer + // carries, an in-repo split with no single range to quote. They keep + // throwing on the first failure, and they all run before any range is + // compared, so a broken precondition can never be reported as drift nor + // read as crosstalk beside one (objectui#4974). Nothing to accumulate here + // either: with the anchor unresolvable there is no expectation to compare a + // generated range against. + const expectedRanges: Record = {}; for (const [name, anchor] of Object.entries(DEV_DEPENDENCY_ANCHORS)) { if (anchor === 'root') { const rootRange = rootRangeOf(name); expect(rootRange, `${name} must exist in the root manifest`).toBeTruthy(); - expect(generated[name], `${name} range must match the repo root`).toBe(rootRange); + expectedRanges[name] = { range: rootRange as string, source: 'the repo root' }; continue; } @@ -367,8 +384,24 @@ describe('generated package.json', () => { ranges.sort(), `in-repo plugins disagree on ${name}: ${JSON.stringify(byRange)} — settle on one range first` ).toHaveLength(1); - expect(generated[name], `${name} range must match packages/plugin-*`).toBe(ranges[0]); + expectedRanges[name] = { range: ranges[0], source: 'its in-repo plugin range' }; + } + + // Pass 2 — the DRIFT, accumulated and reported by a single assertion + // (objectui#4974). Per-name `expect` threw on the first mismatch, so a + // dependabot round moving several of these ranges was one round of repair + // PER NAME, and which name you were told about depended on its POSITION in + // the table. It cost this file directly: the testing-library range sat + // drifted on `main` while the sibling generator's table in `packages/cli` + // was still red, so nothing reported it until that one was green + // (objectui#4968). Each line carries the name, the generated range and the + // range it must be, so the whole batch is fixable from one failure report. + const drifted: string[] = []; + for (const [name, { range, source }] of Object.entries(expectedRanges)) { + if (generated[name] === range) continue; + drifted.push(`${name}: ${generated[name]} must match ${source}, ${range}`); } + expect(drifted).toEqual([]); }); it('keeps the two anchors consistent wherever both declare a dependency', () => {