Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/create-plugin-build-deps-anchored-3742.md
Original file line number Diff line number Diff line change
@@ -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 `<cwd>/packages/plugin-<name>`, 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.
163 changes: 148 additions & 15 deletions packages/create-plugin/src/__tests__/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -69,6 +69,92 @@ function importedPackagesOf(source: string): string[] {
return [...packages].sort();
}

type Manifest = {
devDependencies?: Record<string, string>;
dependencies?: Record<string, string>;
};

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 `<cwd>/packages/plugin-<name>`, 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<string, Manifest> {
const packagesDir = resolve(REPO_ROOT, 'packages');
const manifests: Record<string, Manifest> = {};
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<string, 'root' | 'in-repo-plugins'> = {
'@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<string, string[]> {
const byRange: Record<string, string[]> = {};
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<string, string> {
return (buildPackageJson(vars) as { devDependencies: Record<string, string> }).devDependencies;
}

function declaredDependencies(vars: PluginTemplateVars): Record<string, string> {
const pkg = buildPackageJson(vars) as {
dependencies: Record<string, string>;
Expand All @@ -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<string, string>;
};
const generated = (
buildPackageJson(VARS) as { devDependencies: Record<string, string> }
).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);
}
}
});
});
Expand Down Expand Up @@ -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}']`);

Expand Down
64 changes: 48 additions & 16 deletions packages/create-plugin/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd>/packages/plugin-<name>`, 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<string, string> = {
'@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). */
Expand Down Expand Up @@ -139,6 +159,18 @@ export function buildTsconfig(): Record<string, unknown> {
* 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';
Expand All @@ -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'}\`,
Expand Down
Loading