diff --git a/.changeset/5483-5439-dts-config-wiring.md b/.changeset/5483-5439-dts-config-wiring.md new file mode 100644 index 0000000000..02dff4f5b5 --- /dev/null +++ b/.changeset/5483-5439-dts-config-wiring.md @@ -0,0 +1,47 @@ +--- +'@object-ui/components': patch +'@object-ui/fields': patch +'@object-ui/plugin-ai': patch +'@object-ui/plugin-calendar': patch +'@object-ui/plugin-charts': patch +'@object-ui/plugin-chatbot': patch +'@object-ui/plugin-dashboard': patch +'@object-ui/plugin-designer': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-editor': patch +'@object-ui/plugin-form': patch +'@object-ui/plugin-gantt': patch +'@object-ui/plugin-grid': patch +'@object-ui/plugin-kanban': patch +'@object-ui/plugin-list': patch +'@object-ui/plugin-map': patch +'@object-ui/plugin-markdown': patch +'@object-ui/plugin-report': patch +'@object-ui/plugin-timeline': patch +'@object-ui/plugin-tree': patch +'@object-ui/plugin-view': patch +--- + +Published typings from every `vite-plugin-dts` package now carry an explicit extension on +every relative specifier, and a type error in the declaration build now fails the build +instead of being printed and ignored (objectui#5439, objectui#5483). + +**Consumers on `moduleResolution: nodenext` or `node16` may see NEW type errors, and that +is the fix working.** These packages re-export mostly through NAMED re-exports — +`export { useObjectChat } from './useObjectChat'`. TypeScript could not follow the +extensionless hop, but it still DECLARED the name, so the symbol resolved to a silent +`any`. Nothing errored; consumers simply got no types. With the extension emitted, the +symbol carries its real type, and any call site that was relying on the `any` now type +checks for the first time. This is the mode that produced the 21 residual `TS7006` on +`@object-ui/app-shell` reported against objectui#5365 — a type hole that opened quietly, +unlike objectui#5365's own `export * from './ui'` packages where the same defect surfaced +immediately as `TS2305: has no exported member`. + +410 extensionless relative specifiers across 19 packages were emitted before this change; +the count is now 0 in all 22 packages that build typings through `vite-plugin-dts`. +`@object-ui/fields` was already clean — its sources write explicit `.js` specifiers — and +is wired so it stays that way. + +The second half changes no emitted output today: 22/22 packages built green unmodified, so +making the declaration step's exit code honest turns nothing red. It changes what a FUTURE +regression does — print and exit 0, versus fail the build. diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts index 3621f85152..8b1d44541c 100644 --- a/packages/components/vite.config.ts +++ b/packages/components/vite.config.ts @@ -12,6 +12,7 @@ import dts from 'vite-plugin-dts'; import { resolve } from 'path'; import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; export default defineConfig({ plugins: [ @@ -35,6 +36,9 @@ export default defineConfig({ // verdict about specifier-preserving `.js` builds — correctly never // scanned this package. See the module header for the full argument. ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/fields/vite.config.ts b/packages/fields/vite.config.ts index 709ce91647..cd2a088fc7 100644 --- a/packages/fields/vite.config.ts +++ b/packages/fields/vite.config.ts @@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import path from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; + export default defineConfig({ plugins: [ react(), @@ -14,6 +16,18 @@ export default defineConfig({ // this package's `rootDir` — which would emit TS6059 rootDir errors. compilerOptions: { rootDir: path.resolve(__dirname, 'src'), paths: {} }, aliasesExclude: [/^@object-ui\//], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // Deliberately NOT spreading `createDtsFailOnTypeErrors` here, unlike the + // other 21 `dts(` call sites — objectui#5483. This package's build script is + // `tsc && vite build && node scripts/build-css.mjs`, and that leading `tsc` is + // not redundant: it is what makes a type error fatal for this package, and it + // exits non-zero BEFORE `vite build` ever runs, so a dts-leg exit code could + // never be what decides this build. Drop the `tsc &&` prefix and this package + // owes the factory instead — `scripts/__tests__/vite-dts-wiring-ratchet.test.ts` + // reads that script and fails here if the prefix goes away. }), ], resolve: { diff --git a/packages/plugin-ai/vite.config.ts b/packages/plugin-ai/vite.config.ts index 174e243ce2..36e6907d68 100644 --- a/packages/plugin-ai/vite.config.ts +++ b/packages/plugin-ai/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -16,6 +19,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-calendar/vite.config.ts b/packages/plugin-calendar/vite.config.ts index becc9621e9..590cf27827 100644 --- a/packages/plugin-calendar/vite.config.ts +++ b/packages/plugin-calendar/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ test: { globals: true, @@ -30,6 +33,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-charts/vite.config.ts b/packages/plugin-charts/vite.config.ts index 9c4ead4fbb..222f91a992 100644 --- a/packages/plugin-charts/vite.config.ts +++ b/packages/plugin-charts/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -24,6 +27,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-chatbot/vite.config.ts b/packages/plugin-chatbot/vite.config.ts index c0410aa198..0cc685d6db 100644 --- a/packages/plugin-chatbot/vite.config.ts +++ b/packages/plugin-chatbot/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -24,6 +27,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-dashboard/vite.config.ts b/packages/plugin-dashboard/vite.config.ts index 64400f3a4e..040284d9f0 100644 --- a/packages/plugin-dashboard/vite.config.ts +++ b/packages/plugin-dashboard/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -16,6 +19,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-designer/vite.config.ts b/packages/plugin-designer/vite.config.ts index 2f4b64db0a..7ae70490cd 100644 --- a/packages/plugin-designer/vite.config.ts +++ b/packages/plugin-designer/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -16,6 +19,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-detail/vite.config.ts b/packages/plugin-detail/vite.config.ts index 6226e1117a..e7e52b4674 100644 --- a/packages/plugin-detail/vite.config.ts +++ b/packages/plugin-detail/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -16,6 +19,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], outDir: 'dist', tsconfigPath: './tsconfig.json', + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-editor/vite.config.ts b/packages/plugin-editor/vite.config.ts index 5cb6aa978d..cdf8a99147 100644 --- a/packages/plugin-editor/vite.config.ts +++ b/packages/plugin-editor/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -23,6 +26,13 @@ export default defineConfig({ compilerOptions: { rootDir: resolve(__dirname, 'src'), paths: {} }, aliasesExclude: [/^@object-ui\//], include: ['src'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-form/vite.config.ts b/packages/plugin-form/vite.config.ts index 8f87452d17..56e85460b6 100644 --- a/packages/plugin-form/vite.config.ts +++ b/packages/plugin-form/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -15,6 +18,13 @@ export default defineConfig({ compilerOptions: { rootDir: resolve(__dirname, 'src'), paths: {} }, aliasesExclude: [/^@object-ui\//], include: ['src'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-gantt/vite.config.ts b/packages/plugin-gantt/vite.config.ts index d54cbcde66..c58c4b6c67 100644 --- a/packages/plugin-gantt/vite.config.ts +++ b/packages/plugin-gantt/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -24,6 +27,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-grid/vite.config.ts b/packages/plugin-grid/vite.config.ts index 87497bf6e1..d8b6217d42 100644 --- a/packages/plugin-grid/vite.config.ts +++ b/packages/plugin-grid/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -15,6 +18,13 @@ export default defineConfig({ // this package's `rootDir` — which would emit TS6059 rootDir errors. compilerOptions: { rootDir: resolve(__dirname, 'src'), paths: {} }, aliasesExclude: [/^@object-ui\//], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-kanban/vite.config.ts b/packages/plugin-kanban/vite.config.ts index df63dd7ea2..9628c887b9 100644 --- a/packages/plugin-kanban/vite.config.ts +++ b/packages/plugin-kanban/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -24,6 +27,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-list/vite.config.ts b/packages/plugin-list/vite.config.ts index c18e820200..34530accb9 100644 --- a/packages/plugin-list/vite.config.ts +++ b/packages/plugin-list/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -16,6 +19,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], outDir: 'dist', tsconfigPath: './tsconfig.json', + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-map/vite.config.ts b/packages/plugin-map/vite.config.ts index 470e81e29d..f73cebef3b 100644 --- a/packages/plugin-map/vite.config.ts +++ b/packages/plugin-map/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -24,6 +27,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-markdown/vite.config.ts b/packages/plugin-markdown/vite.config.ts index 74685b9b38..c9fec8c89c 100644 --- a/packages/plugin-markdown/vite.config.ts +++ b/packages/plugin-markdown/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -23,6 +26,13 @@ export default defineConfig({ compilerOptions: { rootDir: resolve(__dirname, 'src'), paths: {} }, aliasesExclude: [/^@object-ui\//], include: ['src'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-report/vite.config.ts b/packages/plugin-report/vite.config.ts index 234cac4c91..ccf9ef9756 100644 --- a/packages/plugin-report/vite.config.ts +++ b/packages/plugin-report/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), @@ -19,6 +22,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-timeline/vite.config.ts b/packages/plugin-timeline/vite.config.ts index 05dcfd8d6b..f4100f50ff 100644 --- a/packages/plugin-timeline/vite.config.ts +++ b/packages/plugin-timeline/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -24,6 +27,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-tree/vite.config.ts b/packages/plugin-tree/vite.config.ts index 3fe43ece19..2795132ac9 100644 --- a/packages/plugin-tree/vite.config.ts +++ b/packages/plugin-tree/vite.config.ts @@ -11,6 +11,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ plugins: [ react(), @@ -24,6 +27,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx', 'node_modules'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/plugin-view/vite.config.ts b/packages/plugin-view/vite.config.ts index 203aa560e6..b62ddd5b20 100644 --- a/packages/plugin-view/vite.config.ts +++ b/packages/plugin-view/vite.config.ts @@ -3,6 +3,9 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; +import { createDtsFailOnTypeErrors } from '../../scripts/vite-dts-fail-on-type-errors.ts'; + export default defineConfig({ define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'), @@ -19,6 +22,13 @@ export default defineConfig({ aliasesExclude: [/^@object-ui\//], include: ['src'], exclude: ['**/*.test.ts', '**/*.test.tsx'], + // Relative specifiers in the EMITTED typings get their explicit extension + // here — objectui#5365 / #5439. A NAMED re-export through an extensionless + // hop still declares the name under `nodenext` and silently types it `any`. + ...createDtsExplicitExtensions({ packageDir: __dirname }), + // A type error the declaration program already found and printed used to + // leave `vite build` exiting 0 — objectui#5370 / #5483. This makes it fatal. + ...createDtsFailOnTypeErrors({ packageDir: __dirname }), }), ], build: { diff --git a/scripts/__tests__/vite-dts-wiring-ratchet.test.ts b/scripts/__tests__/vite-dts-wiring-ratchet.test.ts new file mode 100644 index 0000000000..81b34ace6f --- /dev/null +++ b/scripts/__tests__/vite-dts-wiring-ratchet.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); + +/** + * Every `vite-plugin-dts` build in this repository spreads BOTH shared dts + * factories into its `dts()` call, and this is the gate that keeps it that way. + * + * ## Why a gate and not a convention + * + * Both fixes are one line per `vite.config.ts`, and the absence of that line is + * invisible: the build stays green, the typings still get written, and nothing + * anywhere says a package opted out. That is not a hypothetical failure mode — + * it is the recorded history of both modules: + * + * - `scripts/vite-dts-fail-on-type-errors.ts` landed with objectui#5370 wired + * into ONE of 22 call sites. For the other 21, the declaration program kept + * printing a type error and `vite build` kept exiting 0 (objectui#5483). + * - `scripts/vite-dts-explicit-extensions.ts` landed with objectui#5365 wired + * into TWO of 22. The other 20 kept emitting extensionless relative + * specifiers into `dist/**\/*.d.ts` — 410 of them, measured on the tree this + * gate was written against (objectui#5439). + * + * In the words of objectui#5483: 1-of-22 is "a state someone has to remember", + * and forgetting costs nothing at the moment of forgetting. A new dts package + * cannot land unwired for either reason once this file exists. + * + * ## What is asserted, and why each one + * + * 1. The POPULATION is derived from the tree, never listed here — a + * hand-copied enumeration drifts by construction, and the direction it + * drifts is toward checking fewer packages. + * 2. The population has a FLOOR. A walk that finds nothing must go red, not + * green: a renamed directory or a changed config filename would otherwise + * turn this whole file into a gate that passes because it checks zero + * things — the one failure mode a ratchet cannot notice about itself. + * 3. Both spreads are located by AST, not by substring. `toContain` is + * satisfied by the factory's name appearing in a COMMENT, which is exactly + * the shape of a call site someone disabled and explained. + * 4. Exclusions are a fixed, capped table, and each one's stated reason is + * RE-DERIVED from the tree rather than trusted as prose. An exclusion whose + * premise stopped holding fails here instead of quietly exempting a package + * forever. + */ + +/** The two shared factories every dts build owes. */ +const FACTORIES = [ + { + name: 'createDtsExplicitExtensions', + module: 'scripts/vite-dts-explicit-extensions.ts', + defect: 'extensionless relative specifiers in the emitted typings (objectui#5365 / #5439)', + }, + { + name: 'createDtsFailOnTypeErrors', + module: 'scripts/vite-dts-fail-on-type-errors.ts', + defect: '`vite build` exiting 0 on a type error the dts program printed (objectui#5370 / #5483)', + }, +] as const; + +/** + * The floor the population may not fall below. + * + * 22 config files call `dts(` today. The floor is deliberately BELOW that — it + * is a vacuity guard, not a second copy of the count, so retiring a plugin + * package is an ordinary green change while a walk that resolves nothing is + * not. Raise it only alongside a reason that the smaller number is impossible. + */ +const POPULATION_FLOOR = 15; + +/** Read a workspace package's `build` script. */ +function buildScript(pkgDir: string): string { + const manifest = path.join(ROOT, pkgDir, 'package.json'); + return JSON.parse(fs.readFileSync(manifest, 'utf8')).scripts?.build ?? ''; +} + +/** + * Call sites deliberately NOT wired to one factory. + * + * `holds()` re-derives the stated reason from the tree. A reason that stops + * being true takes the exclusion with it, so this table cannot decay into a + * list of packages nobody remembers exempting. + */ +const EXCLUSIONS: ReadonlyArray<{ + readonly config: string; + readonly factory: string; + readonly reason: string; + readonly holds: () => boolean; +}> = [ + { + config: 'packages/fields/vite.config.ts', + factory: 'createDtsFailOnTypeErrors', + reason: + "@object-ui/fields builds with `tsc && vite build && …`; the leading `tsc` exits " + + 'non-zero on the same diagnostics BEFORE `vite build` runs, so the dts leg’s exit ' + + 'code is not what decides this build (objectui#5483).', + holds: () => /^tsc\s*&&/.test(buildScript('packages/fields')), + }, +]; + +/** + * Ceiling on the exclusion table. + * + * Equal to its length today, on purpose: adding an exclusion means editing two + * places and reading this paragraph, which is the review point. The number may + * go DOWN freely — that is a package getting wired. + */ +const MAX_EXCLUSIONS = 1; + +function isExcluded(config: string, factory: string): boolean { + return EXCLUSIONS.some((e) => e.config === config && e.factory === factory); +} + +/** Every `vite.config.*` under the workspace roots, repo-relative. */ +function viteConfigs(): string[] { + const found: string[] = []; + for (const root of ['packages', 'apps', 'examples']) { + const dir = path.join(ROOT, root); + if (!fs.existsSync(dir)) continue; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + for (const name of ['vite.config.ts', 'vite.config.mts', 'vite.config.js']) { + const file = path.join(dir, entry.name, name); + if (fs.existsSync(file)) found.push(path.relative(ROOT, file)); + } + } + } + return found.sort(); +} + +interface DtsCallSite { + /** Repo-relative path to the config. */ + readonly config: string; + /** Factory names spread into the `dts()` options object, by AST. */ + readonly spreads: ReadonlySet; + /** Module specifiers the config imports, by AST. */ + readonly imports: ReadonlySet; +} + +/** + * The `dts()` call in a config, or null if it has none. + * + * `dts` must be the local name bound to the `vite-plugin-dts` default import — + * a same-named local helper is not this population, and pretending otherwise + * would let a package join the gate's subject set by coincidence. + */ +function dtsCallSite(config: string): DtsCallSite | null { + const file = path.join(ROOT, config); + const source = ts.createSourceFile( + file, + fs.readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + /* setParentNodes */ true, + ); + + const imports = new Set(); + let dtsLocalName: string | null = null; + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue; + imports.add(statement.moduleSpecifier.text); + if (statement.moduleSpecifier.text !== 'vite-plugin-dts') continue; + const name = statement.importClause?.name; + if (name !== undefined) dtsLocalName = name.text; + } + if (dtsLocalName === null) return null; + + const spreads = new Set(); + let sawCall = false; + const visit = (node: ts.Node): void => { + if ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === dtsLocalName + ) { + sawCall = true; + const [options] = node.arguments; + if (options !== undefined && ts.isObjectLiteralExpression(options)) { + for (const property of options.properties) { + if (!ts.isSpreadAssignment(property)) continue; + const spread = property.expression; + if (ts.isCallExpression(spread) && ts.isIdentifier(spread.expression)) { + spreads.add(spread.expression.text); + } + } + } + } + node.forEachChild(visit); + }; + visit(source); + + return sawCall ? { config, spreads, imports } : null; +} + +const CONFIGS = viteConfigs(); +const CALL_SITES = CONFIGS.map(dtsCallSite).filter((s): s is DtsCallSite => s !== null); + +describe(`vite-plugin-dts wiring ratchet — ${CALL_SITES.length} dts( call sites of ${CONFIGS.length} vite configs`, () => { + it(`finds a real population: ${CALL_SITES.length} call sites, floor ${POPULATION_FLOOR}`, () => { + // Non-vacuity. Everything below is a statement about CALL_SITES, so an + // empty walk would make every one of those assertions pass by having + // nothing to judge. This is the assertion that cannot be satisfied that way. + expect(CALL_SITES.length).toBeGreaterThanOrEqual(POPULATION_FLOOR); + expect(CONFIGS.length).toBeGreaterThanOrEqual(CALL_SITES.length); + }); + + for (const factory of FACTORIES) { + it(`every dts( call site spreads ${factory.name}`, () => { + const missing = CALL_SITES.filter( + (site) => !site.spreads.has(factory.name) && !isExcluded(site.config, factory.name), + ).map((site) => site.config); + + expect( + missing, + `${missing.length} vite-plugin-dts call site(s) do not spread ` + + `\`${factory.name}({ packageDir: __dirname })\`, so they still carry ` + + `${factory.defect}:\n` + + missing.map((c) => ` - ${c}`).join('\n') + + `\n\nAdd the spread, in the form pinned by packages/layout/vite.config.ts. If a ` + + `package genuinely cannot be wired, that is a finding worth an issue — and an ` + + `entry in EXCLUSIONS above with a reason this file can re-derive, not a silent ` + + `omission.`, + ).toEqual([]); + }); + + it(`every call site that spreads ${factory.name} imports it from ${factory.module}`, () => { + // A spread of a same-named local function would satisfy the assertion + // above while running none of the shared module's code. + const wrong = CALL_SITES.filter((site) => site.spreads.has(factory.name)).filter((site) => { + const wanted = path + .relative(path.dirname(path.join(ROOT, site.config)), path.join(ROOT, factory.module)) + .split(path.sep) + .join('/'); + return !site.imports.has(wanted); + }); + expect(wrong.map((s) => s.config)).toEqual([]); + }); + } + + it('exclusions stay capped, and each one names a live call site', () => { + expect(EXCLUSIONS.length).toBeLessThanOrEqual(MAX_EXCLUSIONS); + for (const exclusion of EXCLUSIONS) { + expect( + CALL_SITES.some((site) => site.config === exclusion.config), + `EXCLUSIONS names ${exclusion.config}, which is not a dts( call site any more. ` + + `A stale exclusion must go red here rather than exempt a path that no longer exists.`, + ).toBe(true); + expect(FACTORIES.map((f) => f.name)).toContain(exclusion.factory); + } + }); + + it('every exclusion re-derives its stated reason from the tree', () => { + for (const exclusion of EXCLUSIONS) { + expect( + exclusion.holds(), + `The reason ${exclusion.config} is exempt from ${exclusion.factory} no longer holds:\n` + + ` ${exclusion.reason}\n` + + `Wire the factory in, or replace the exclusion with one whose premise is true.`, + ).toBe(true); + } + }); +});