From e7909d25ce5e99dda22b2e52dd3af8ce7a86747d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 11:57:45 +0000 Subject: [PATCH] fix(create-plugin): anchor the scaffold's build devDependencies and pin the whole manifest The five build-side ranges in `DEV_DEPENDENCIES` were never sourced from anything: the scaffold declared vite ^7.3.1, @vitejs/plugin-react ^4.2.1, vite-plugin-dts ^4.5.4, typescript ^5.9.3 and vitest ^4.0.18 while this repo builds and tests every in-tree plugin on ^8.2.0 / ^6.0.5 / ^5.0.3 / ^6.0.3 / ^4.1.10. objectui#3716's end-to-end run of the generated artifact only ever exercised the in-repo versions, so the declared ranges were not under test. All five now quote an in-repo anchor. Two anchors are needed: the generator writes into `/packages/plugin-`, so a generated plugin is a sibling of `packages/plugin-*`, and those manifests anchor the two build-only tools the root manifest omits (@vitejs/plugin-react, vite-plugin-dts) while the root anchors the rest. All 19 in-repo plugins already agree on both ranges. The parity test now covers every entry of the generated devDependencies instead of the three testing ones, plus a completeness check that fails on a dependency added without an anchor, and a cross-check that the two anchors agree wherever both declare a dependency. Also switches the generated vite.config.ts from `__dirname` to `import.meta.dirname`. vite 8 still defines `__dirname` under its default `bundle` config loader but warns on it, and under `configLoader: 'native'` -- planned to become the default -- the generated config failed to load outright ("__dirname is not defined in ES module scope"). Same conversion apps/console/vite.config.ts got in objectui#3384. Not a peer-dependency fix: @vitejs/plugin-react ^4.2.1 resolved to 4.7.0, whose vite peer had widened to accept vite 7, so the old manifest installed cleanly under strict-peer-dependencies. The cost was a scaffold one to two majors behind its own monorepo. Fixes #3742 Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt --- .../create-plugin-build-deps-anchored-3742.md | 15 ++ .../src/__tests__/templates.test.ts | 163 ++++++++++++++++-- packages/create-plugin/src/templates.ts | 64 +++++-- 3 files changed, 211 insertions(+), 31 deletions(-) create mode 100644 .changeset/create-plugin-build-deps-anchored-3742.md diff --git a/.changeset/create-plugin-build-deps-anchored-3742.md b/.changeset/create-plugin-build-deps-anchored-3742.md new file mode 100644 index 0000000000..7230cd8d7e --- /dev/null +++ b/.changeset/create-plugin-build-deps-anchored-3742.md @@ -0,0 +1,15 @@ +--- +"@object-ui/create-plugin": patch +--- + +Anchor the scaffold's build-side `devDependencies` to this repo's real toolchain, and pin the whole generated manifest against drift + +A freshly scaffolded plugin declared a build stack one to two majors behind the one this monorepo actually builds and tests every in-tree plugin with: `vite ^7.3.1` against the repo's `^8.2.0`, `@vitejs/plugin-react ^4.2.1` against `^6.0.5`, `vite-plugin-dts ^4.5.4` against `^5.0.3`, `typescript ^5.9.3` against `^6.0.3`, `vitest ^4.0.18` against `^4.1.10`. Those five ranges were never sourced from anything — objectui#3716's end-to-end run of the generated artifact only ever exercised the versions installed in this repo, so the declared ranges were not the ones under test. All five now quote an in-repo anchor, the same way the three testing ranges already did. + +Two anchors, because the root manifest does not declare everything. `create-plugin` writes into `/packages/plugin-`, so a generated plugin is a literal sibling of `packages/plugin-*`; those manifests anchor the two build-only tools the root omits (`@vitejs/plugin-react`, `vite-plugin-dts`), and the root anchors the rest. + +The parity test now covers **every** entry of the generated `devDependencies` rather than the three testing ones, including a completeness check that fails when a dependency is added without naming its anchor — the five build ranges drifted precisely because nothing pinned them. It also asserts the two anchors agree wherever both declare a dependency, so which one is read cannot hide a drift. + +The generated `vite.config.ts` resolves its library entry from `import.meta.dirname` instead of `__dirname`. vite 8 still defines `__dirname` under its default `bundle` config loader but warns on it ("unsupported by `configLoader: 'native'`, which is planned to become the default in a future major version of Vite ... Use `import.meta.dirname` instead"), and under `native` — which imports the config with Node's own ESM loader, where no `__dirname` exists — the generated config failed to load outright. `apps/console/vite.config.ts` was converted for the same reason in objectui#3384. + +Not a peer-dependency fix: `@vitejs/plugin-react ^4.2.1` resolved to 4.7.0, whose vite peer had widened to `^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0` and accepted the declared `vite ^7.3.1`, so the old manifest installed cleanly. The cost was a scaffold lagging its own monorepo, not a failing install. diff --git a/packages/create-plugin/src/__tests__/templates.test.ts b/packages/create-plugin/src/__tests__/templates.test.ts index 51f708e289..28df66fcec 100644 --- a/packages/create-plugin/src/__tests__/templates.test.ts +++ b/packages/create-plugin/src/__tests__/templates.test.ts @@ -18,7 +18,7 @@ * generator actually writes. */ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { isBuiltin } from 'node:module'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -69,6 +69,92 @@ function importedPackagesOf(source: string): string[] { return [...packages].sort(); } +type Manifest = { + devDependencies?: Record; + dependencies?: Record; +}; + +function readManifest(path: string): Manifest { + return JSON.parse(readFileSync(path, 'utf-8')) as Manifest; +} + +/** The repo root `package.json` — anchor for every dependency it declares. */ +function rootManifest(): Manifest { + return readManifest(resolve(REPO_ROOT, 'package.json')); +} + +/** + * `packages/plugin-*` manifests, as `package name -> manifest`. + * + * `create-plugin` writes into `/packages/plugin-`, so a generated + * plugin is a literal sibling of these — which makes them the faithful anchor + * for build dependencies the root manifest does not declare + * (`@vitejs/plugin-react`, `vite-plugin-dts`). + */ +function inRepoPluginManifests(): Record { + const packagesDir = resolve(REPO_ROOT, 'packages'); + const manifests: Record = {}; + for (const entry of readdirSync(packagesDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('plugin-')) continue; + const manifestPath = resolve(packagesDir, entry.name, 'package.json'); + if (!existsSync(manifestPath)) continue; + manifests[entry.name] = readManifest(manifestPath); + } + return manifests; +} + +/** + * Which in-repo manifest each generated devDependency range must quote. + * + * Anchoring is what keeps ONE range per dependency in this repo instead of one + * per file. `root` is preferred wherever the dependency exists there (that is + * the anchor objectui#3733 chose for the three testing entries); the two + * build-only tools the root does not declare are anchored to the in-repo plugin + * manifests the generated package sits beside. + * + * 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). + */ +const DEV_DEPENDENCY_ANCHORS: Record = { + '@testing-library/jest-dom': 'root', + '@testing-library/react': 'root', + '@vitejs/plugin-react': 'in-repo-plugins', + jsdom: 'root', + typescript: 'root', + vite: 'root', + 'vite-plugin-dts': 'in-repo-plugins', + vitest: 'root' +}; + +/** The range `name` is declared at in the root manifest, if it is declared there. */ +function rootRangeOf(name: string): string | undefined { + const manifest = rootManifest(); + return manifest.devDependencies?.[name] ?? manifest.dependencies?.[name]; +} + +/** + * The range every in-repo plugin declaring `name` agrees on. + * + * Returns the distinct ranges found, keyed by range, so a divergence names the + * offending manifests instead of just failing. A split here is itself a finding + * — this repo's rule is one range per dependency — so the parity test asserts + * unanimity rather than picking a winner. + */ +function inRepoPluginRangesOf(name: string): Record { + const byRange: Record = {}; + for (const [pluginDir, manifest] of Object.entries(inRepoPluginManifests())) { + const range = manifest.devDependencies?.[name] ?? manifest.dependencies?.[name]; + if (range === undefined) continue; + (byRange[range] ??= []).push(pluginDir); + } + return byRange; +} + +function generatedDevDependencies(vars: PluginTemplateVars): Record { + return (buildPackageJson(vars) as { devDependencies: Record }).devDependencies; +} + function declaredDependencies(vars: PluginTemplateVars): Record { const pkg = buildPackageJson(vars) as { dependencies: Record; @@ -94,24 +180,58 @@ describe('generated package.json', () => { ); }); - it('sources the testing ranges from this repo instead of inventing them', () => { + it('anchors every devDependency range, leaving none unpinned', () => { + // The completeness gate. objectui#3733 pinned only the three testing + // ranges, and the five build ranges beside them drifted one to two majors + // behind the repo's own toolchain unnoticed (objectui#3742). Adding a + // devDependency to the template without naming its anchor fails here. + const generated = generatedDevDependencies(VARS); + expect(Object.keys(generated).sort()).toEqual(Object.keys(DEV_DEPENDENCY_ANCHORS).sort()); + }); + + it('sources every devDependency range from this repo instead of inventing them', () => { // These literals live in `.ts` source, outside the objectui#3711 // version-claims gate's scan face, so this is the gate for them: the // template must quote the monorepo's own range for the same package. - // Bumping the root manifest and leaving the template behind is the drift + // Bumping an in-repo manifest and leaving the template behind is the drift // this test exists to catch — update `src/templates.ts` in the same PR. - const rootPkg = JSON.parse(readFileSync(resolve(REPO_ROOT, 'package.json'), 'utf-8')) as { - devDependencies: Record; - }; - const generated = ( - buildPackageJson(VARS) as { devDependencies: Record } - ).devDependencies; - - for (const name of ['@testing-library/react', '@testing-library/jest-dom', 'jsdom']) { - expect(rootPkg.devDependencies[name], `${name} must exist in the root manifest`).toBeTruthy(); - expect(generated[name], `${name} range must match the repo root`).toBe( - rootPkg.devDependencies[name] - ); + const generated = generatedDevDependencies(VARS); + + 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); + continue; + } + + const byRange = inRepoPluginRangesOf(name); + const ranges = Object.keys(byRange); + expect( + ranges.length, + `${name} must be declared by at least one packages/plugin-* manifest to anchor to` + ).toBeGreaterThan(0); + expect( + 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]); + } + }); + + it('keeps the two anchors consistent wherever both declare a dependency', () => { + // Makes the anchor CHOICE non-load-bearing: for anything declared both at + // the root and in the plugin manifests, the two must already agree, so + // reading one instead of the other cannot hide a drift. + for (const name of Object.keys(DEV_DEPENDENCY_ANCHORS)) { + const rootRange = rootRangeOf(name); + if (rootRange === undefined) continue; + for (const [range, plugins] of Object.entries(inRepoPluginRangesOf(name))) { + expect( + range, + `${name} is ${rootRange} at the repo root but ${range} in ${plugins.join(', ')}` + ).toBe(rootRange); + } } }); }); @@ -153,6 +273,19 @@ describe('generated vite.config.ts', () => { expect(viteConfig).toContain('globals: true'); }); + it('resolves paths with import.meta.dirname so it survives configLoader native', () => { + // vite 8 still defines `__dirname` under its default `bundle` config + // loader, but warns on it ("... unsupported by `configLoader: 'native'`, + // which is planned to become the default ... Use `import.meta.dirname` + // instead") and would fail outright once `native` is the default: that + // loader imports the config with Node's own ESM loader, which defines no + // `__dirname`. Same conversion `apps/console/vite.config.ts` got in + // objectui#3384 (objectui#3742). + expect(viteConfig).toContain('path.resolve(import.meta.dirname,'); + expect(viteConfig).not.toContain('__dirname'); + expect(viteConfig).not.toContain('__filename'); + }); + it('points setupFiles at a file the generator actually writes', () => { expect(viteConfig).toContain(`setupFiles: ['./${VITEST_SETUP_FILE}']`); diff --git a/packages/create-plugin/src/templates.ts b/packages/create-plugin/src/templates.ts index e0d8a3e31d..801fc76978 100644 --- a/packages/create-plugin/src/templates.ts +++ b/packages/create-plugin/src/templates.ts @@ -45,33 +45,53 @@ export const VITEST_SETUP_FILE = 'vitest.setup.ts'; /** * devDependencies written into the generated plugin. * - * The three testing entries are SOURCED, not invented — each copies this - * monorepo's own range for the same package verbatim, so the repo has one - * range per dependency rather than one per file (objectui#3716; these literals - * sit in `.ts` source, outside the objectui#3711 version-claims gate's scan - * face, so `templates.test.ts` pins the parity instead): + * EVERY entry is SOURCED, not invented — each copies this monorepo's own range + * for the same package verbatim, so the repo has one range per dependency + * rather than one per file. These literals sit in `.ts` source, outside the + * objectui#3711 version-claims gate's scan face, so `templates.test.ts` pins + * the parity instead — and it pins the WHOLE map, not a subset, which is what + * stops a re-anchored entry from silently rotting again (objectui#3742). * - * - `@testing-library/jest-dom` `^7.0.0` — repo root package.json (also apps/console) - * - `@testing-library/react` `^16.3.2` — repo root package.json (also apps/console) - * - `jsdom` `^30.0.1` — repo root package.json + * Two anchors, because not every build dependency exists in the root manifest. + * `create-plugin` writes into `/packages/plugin-`, so a generated + * plugin is a literal sibling of `packages/plugin-*` — those manifests are the + * faithful anchor for anything the root does not declare: + * + * | dependency | range | anchor | + * | --------------------------- | --------- | ------------------------------------------ | + * | `@testing-library/jest-dom` | `^7.0.0` | repo root package.json (also apps/console) | + * | `@testing-library/react` | `^16.3.2` | repo root package.json (also apps/console) | + * | `@vitejs/plugin-react` | `^6.0.5` | every `packages/plugin-*` (not in root) | + * | `jsdom` | `^30.0.1` | repo root package.json | + * | `typescript` | `^6.0.3` | repo root package.json | + * | `vite` | `^8.2.0` | repo root package.json | + * | `vite-plugin-dts` | `^5.0.3` | every `packages/plugin-*` (not in root) | + * | `vitest` | `^4.1.10` | repo root package.json | * * `@testing-library/dom` is deliberately absent: it is a peer of * `@testing-library/react` 16 and is installed by the workspace's * `auto-install-peers=true`, which is also why `apps/console` declares the * same three and not four. * - * The five build-side entries below keep the ranges they have shipped with; - * re-anchoring those is a separate change, not part of this fix. + * The build entries carried pre-anchoring ranges until objectui#3742 — the + * scaffold handed authors vite 7 / plugin-react 4 / dts 4 / TypeScript 5 while + * the repo built and tested every in-tree plugin on vite 8 / plugin-react 6 / + * dts 5 / TypeScript 6. objectui#3716's artifact end-to-end run only ever + * exercised the in-repo versions, so the declared ranges were never the ones + * under test. Not a peer conflict: `^4.2.1` resolved to plugin-react 4.7.0, + * whose vite peer had widened to `^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0` and + * accepted vite 7 — the cost was a scaffold one to two majors behind its own + * monorepo, not a failing install. */ const DEV_DEPENDENCIES: Record = { '@testing-library/jest-dom': '^7.0.0', '@testing-library/react': '^16.3.2', - '@vitejs/plugin-react': '^4.2.1', + '@vitejs/plugin-react': '^6.0.5', jsdom: '^30.0.1', - typescript: '^5.9.3', - vite: '^7.3.1', - 'vite-plugin-dts': '^4.5.4', - vitest: '^4.0.18' + typescript: '^6.0.3', + vite: '^8.2.0', + 'vite-plugin-dts': '^5.0.3', + vitest: '^4.1.10' }; /** The generated plugin's `package.json`, as an object (not yet serialised). */ @@ -139,6 +159,18 @@ export function buildTsconfig(): Record { * tests, silently, as soon as the author writes a second one. * - `setupFiles` — where the jest-dom matchers get registered; see * {@link buildVitestSetup}. + * + * The entry path is resolved from `import.meta.dirname`, not `__dirname`. Vite + * still defines `__dirname` under its default `configLoader: 'bundle'`, but + * vite 8 warns on it ("Your Vite config uses features that are unsupported by + * `configLoader: 'native'`, which is planned to become the default in a future + * major version of Vite ... Use `import.meta.dirname` instead"), and under + * `native` — which imports the config with Node's own ESM loader, where no + * `__dirname` exists — it would fail outright. `apps/console/vite.config.ts` + * was converted for the same reason (objectui#3384); the generated config now + * ships already-correct rather than warning on an author's first `pnpm build`. + * Safe unconditionally here: the repo requires Node >= 22 and vite defines + * `import.meta.dirname` under the bundle loader too. */ export function buildViteConfig(vars: PluginTemplateVars): string { return `import { defineConfig } from 'vite'; @@ -155,7 +187,7 @@ export default defineConfig({ ], build: { lib: { - entry: path.resolve(__dirname, 'src/index.tsx'), + entry: path.resolve(import.meta.dirname, 'src/index.tsx'), name: '${vars.pascalName}', formats: ['es', 'umd'], fileName: (format) => \`index.\${format === 'es' ? 'js' : 'umd.cjs'}\`,