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', () => {