From d2a71d647ac44457d14c528c12afac4af2707bc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 17:46:42 +0000 Subject: [PATCH 1/3] fix(components,layout): emit explicit extensions in dist typings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `vite-plugin-dts` copies module specifiers into the declaration output verbatim, so `export * from './ui'` shipped extensionless in `dist/index.d.ts` and no consumer on `moduleResolution: nodenext` could follow any hop — every named export read as missing (objectui#5365). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- packages/components/vite.config.ts | 12 + packages/layout/vite.config.ts | 12 + scripts/vite-dts-explicit-extensions.ts | 320 ++++++++++++++++++++++++ 3 files changed, 344 insertions(+) create mode 100644 scripts/vite-dts-explicit-extensions.ts diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts index 921fb59129..6df967b8c3 100644 --- a/packages/components/vite.config.ts +++ b/packages/components/vite.config.ts @@ -11,6 +11,8 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions'; + export default defineConfig({ plugins: [ react(), @@ -23,6 +25,16 @@ 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. `tsc` copies a module specifier into + // the declaration verbatim, so `export * from './ui'` shipped + // extensionless and no consumer on `moduleResolution: nodenext` could + // follow it; every named export of this package read as missing. The + // `.js` never had the defect because rolldown resolves the same + // specifier away, which is also why `pnpm check:esm-specifiers` — a + // verdict about specifier-preserving `.js` builds — correctly never + // scanned this package. See the module header for the full argument. + ...createDtsExplicitExtensions({ packageDir: __dirname }), }), ], resolve: { diff --git a/packages/layout/vite.config.ts b/packages/layout/vite.config.ts index e961ba24be..a66b6facb5 100644 --- a/packages/layout/vite.config.ts +++ b/packages/layout/vite.config.ts @@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions'; + export default defineConfig({ plugins: [ react(), @@ -15,6 +17,16 @@ 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. `tsc` copies a module specifier into + // the declaration verbatim, so `export * from './ui'` shipped + // extensionless and no consumer on `moduleResolution: nodenext` could + // follow it; every named export of this package read as missing. The + // `.js` never had the defect because rolldown resolves the same + // specifier away, which is also why `pnpm check:esm-specifiers` — a + // verdict about specifier-preserving `.js` builds — correctly never + // scanned this package. See the module header for the full argument. + ...createDtsExplicitExtensions({ packageDir: __dirname }), }), ], build: { diff --git a/scripts/vite-dts-explicit-extensions.ts b/scripts/vite-dts-explicit-extensions.ts new file mode 100644 index 0000000000..55bcc098a0 --- /dev/null +++ b/scripts/vite-dts-explicit-extensions.ts @@ -0,0 +1,320 @@ +// Explicit extensions on every relative specifier a `vite-plugin-dts` build +// emits into `dist/**/*.d.ts`. +// +// ## The defect (objectui#5365) +// +// `packages/components/dist/index.d.ts` re-exported through EXTENSIONLESS +// relative specifiers — `export * from './ui'`, `export { cn } from +// './lib/utils'`, 21 of them in that one file. TypeScript under +// `"moduleResolution": "nodenext"` (or `node16`) does not extension-search a +// relative specifier, so it could follow none of the hops and every symbol they +// carried read as ABSENT from the package: +// +// error TS2305: Module '"@object-ui/components"' has no exported member 'Badge'. +// +// Measured on `@object-ui/app-shell` with the pin applied: 1097 errors, of which +// 880 TS2305 (864 from `@object-ui/components`, 16 from `@object-ui/layout`) +// across 162 source files. The remaining TS7006 were fallout — parameters whose +// types came from the imports that stopped resolving. +// +// ## Why the fix has to live in the EMIT, not the source +// +// `@object-ui/react` had the same defect in its `.js` (objectui#4538) and fixed +// it at the source, because it builds with a bare emitting `tsc` and +// **TypeScript never rewrites import specifiers** — what the source writes is +// what `dist` ships. `@object-ui/components` and `@object-ui/layout` are +// different on exactly one axis that decides the route: they are BUNDLER builds. +// Rolldown resolves their relative specifiers away, so the `.js` is clean and +// `pnpm check:esm-specifiers` correctly judges neither package +// specifier-preserving and never scans it. The typings are emitted by the other +// half of the same build, one declaration file per source file, and there the +// source specifier survives verbatim. So the `.js` is fine and the `.d.ts` is +// broken from the same source line, and no source edit can express the +// difference. +// +// That is the whole reason this module exists rather than 350 source edits: the +// extension is a property of the DECLARATION OUTPUT here, not of the source. +// +// ## Two properties this module holds on purpose +// +// - **Loud, never lenient.** A specifier this module cannot resolve to a file +// the build will emit THROWS, naming the declaration file and the specifier. +// The tempting fallback — "leave it alone if it looks odd" — reproduces the +// exact silence objectui#5365 is about: an unreachable hop that every gate +// reads as green. +// - **It proves its own postcondition.** `afterBuild` re-parses the emitted +// declaration files and asserts that every relative specifier now carries an +// extension AND names a file the build actually emitted. Rewriting strings is +// not the deliverable; RESOLVABLE published typings are, and only the second +// check can tell the two apart. It also asserts it saw declaration files at +// all, so a wiring change that stops calling the hook fails the build instead +// of silently restoring the defect. +// +// Note what this module is NOT: a repository-wide gate. `pnpm check:esm-specifiers` +// deliberately scopes its specifier leg to specifier-preserving `.js` builds, +// and a typings-level criterion is a THIRD leg with its own size assertion — +// out of scope here (objectui#5365 triage), filed separately. The checks below +// are a build's assertions about its own output. + +import fs from 'node:fs'; +import path from 'node:path'; + +import ts from 'typescript'; + +/** Source extensions, mapped to the specifier extension their declaration wants. */ +const SOURCE_TO_SPECIFIER_EXTENSION: ReadonlyArray = [ + ['.ts', '.js'], + ['.tsx', '.js'], + ['.d.ts', '.js'], + ['.mts', '.mjs'], + ['.d.mts', '.mjs'], + ['.cts', '.cjs'], + ['.d.cts', '.cjs'], +]; + +/** + * Specifier suffixes that are already explicit and must be left alone. + * + * `.css` / `.json` / asset suffixes are here because a declaration file can + * legitimately carry one; they are not module hops this module can or should + * re-point. + */ +const ALREADY_EXPLICIT = /\.(js|mjs|cjs|jsx|json|css|scss|svg|png|node)$/; + +/** Declaration files this module rewrites. Source maps and assets are skipped. */ +const DECLARATION_FILE = /\.d\.(ts|mts|cts)$/; + +/** One module specifier found in a declaration file, with its source span. */ +interface SpecifierSpan { + /** The specifier text, without quotes. */ + text: string; + /** Offset of the opening quote in the file content. */ + start: number; + /** Offset just past the closing quote. */ + end: number; +} + +function fail(message: string): never { + throw new Error(`[dts-explicit-extensions] ${message}`); +} + +/** + * Every module specifier in a declaration file, located by the TypeScript + * parser rather than by a regular expression. + * + * The parser is load-bearing, not fastidiousness. `removeComments` is false in + * this repository's build configs, so JSDoc survives into `dist`, and this + * repository's JSDoc contains PROSE specifiers — `packages/app-shell`'s + * `i18n.ts` documents itself with `import { t } from './i18n'` inside a comment, + * and `packages/fields`' `MasterDetailField.tsx` names `'./widgets/MasterDetailField'` + * the same way. A text scan rewrites those (harmless but wrong) or, when the + * prose names a path that does not exist, THROWS and breaks a correct build. + * Module augmentations (`declare module './x'`) are deliberately not collected: + * their specifier is a module NAME, not a hop to follow. + */ +export function findModuleSpecifiers(fileName: string, content: string): SpecifierSpan[] { + const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true); + const found: SpecifierSpan[] = []; + + const record = (node: ts.Node | undefined): void => { + if (!node || !ts.isStringLiteralLike(node)) return; + found.push({ text: node.text, start: node.getStart(sourceFile), end: node.getEnd() }); + }; + + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) { + record(node.moduleSpecifier); + } else if (ts.isImportTypeNode(node)) { + if (ts.isLiteralTypeNode(node.argument)) record(node.argument.literal); + } else if (ts.isImportEqualsDeclaration(node)) { + if (ts.isExternalModuleReference(node.moduleReference)) record(node.moduleReference.expression); + } else if ( + ts.isCallExpression(node) && + node.expression.kind === ts.SyntaxKind.ImportKeyword && + node.arguments.length > 0 + ) { + record(node.arguments[0]); + } + ts.forEachChild(node, visit); + }; + + ts.forEachChild(sourceFile, visit); + return found; +} + +/** True for a specifier that names a path rather than a package. */ +function isRelative(specifier: string): boolean { + return specifier === '.' || specifier === '..' || /^\.\.?\//.test(specifier); +} + +/** + * The explicit form of one relative specifier, resolved against the SOURCE tree + * the declaration output mirrors. + * + * Resolving against the sources rather than against `dist` is what makes this + * usable from `beforeWriteFile`, which runs per file while the rest of the + * output does not exist yet. `afterBuild` then re-checks the answers against + * what was really emitted, so the mirror assumption is verified rather than + * trusted. + */ +export function resolveExplicitSpecifier( + specifier: string, + sourceDir: string, + exists: (candidate: string) => boolean = fs.existsSync +): string | null { + if (ALREADY_EXPLICIT.test(specifier)) return null; + + const bareDirectory = specifier === '.' || specifier === '..' || specifier.endsWith('/'); + const target = path.resolve(sourceDir, specifier); + + if (!bareDirectory) { + for (const [sourceExtension, specifierExtension] of SOURCE_TO_SPECIFIER_EXTENSION) { + if (exists(`${target}${sourceExtension}`)) return `${specifier}${specifierExtension}`; + } + } + + const prefix = bareDirectory ? specifier.replace(/\/$/, '') : specifier; + for (const [sourceExtension, specifierExtension] of SOURCE_TO_SPECIFIER_EXTENSION) { + if (exists(path.join(target, `index${sourceExtension}`))) { + return `${prefix}/index${specifierExtension}`; + } + } + + return null; +} + +/** + * One declaration file's content, with every relative specifier made explicit. + * + * @param declarationPath absolute path the file will be written to + * @param content the emitted declaration text + * @param sourceDir the source directory that declaration mirrors + */ +export function rewriteDeclaration( + declarationPath: string, + content: string, + sourceDir: string, + exists: (candidate: string) => boolean = fs.existsSync +): string { + const spans = findModuleSpecifiers(declarationPath, content); + let result = content; + + // Applied back-to-front so an earlier span's offsets stay valid. + for (let index = spans.length - 1; index >= 0; index -= 1) { + const span = spans[index]; + if (!isRelative(span.text)) continue; + + const explicit = resolveExplicitSpecifier(span.text, sourceDir, exists); + if (explicit === null) { + if (ALREADY_EXPLICIT.test(span.text)) continue; + fail( + `\`${declarationPath}\` re-exports through \`${span.text}\`, which names no file under ` + + `\`${sourceDir}\`. A relative specifier with no extension is unresolvable under ` + + `\`moduleResolution: nodenext\`, and this build cannot guess the extension it wants.` + ); + } + + const quote = content[span.start]; + result = `${result.slice(0, span.start)}${quote}${explicit}${quote}${result.slice(span.end)}`; + } + + return result; +} + +export interface DtsExplicitExtensionsOptions { + /** Absolute path of the package root (the directory holding `package.json`). */ + packageDir: string; + /** Source root the declarations mirror. Defaults to `/src`. */ + srcDir?: string; + /** Declaration output root. Defaults to `/dist`. */ + outDir?: string; +} + +/** The two `vite-plugin-dts` hooks that carry the fix. */ +export interface DtsExplicitExtensionsHooks { + beforeWriteFile: (filePath: string, content: string) => { content: string } | undefined; + afterBuild: (emitted: Map) => void; +} + +/** + * Wire the fix into a `vite-plugin-dts` invocation. + * + * ```ts + * const dtsExtensions = createDtsExplicitExtensions({ packageDir: __dirname }); + * dts({ ...existing, ...dtsExtensions }) + * ``` + */ +export function createDtsExplicitExtensions( + options: DtsExplicitExtensionsOptions +): DtsExplicitExtensionsHooks { + const packageDir = path.resolve(options.packageDir); + const srcDir = path.resolve(options.srcDir ?? path.join(packageDir, 'src')); + const outDir = path.resolve(options.outDir ?? path.join(packageDir, 'dist')); + + let rewritten = 0; + + const sourceDirFor = (declarationPath: string): string => + path.join(srcDir, path.dirname(path.relative(outDir, declarationPath))); + + return { + beforeWriteFile(filePath, content) { + if (!DECLARATION_FILE.test(filePath)) return undefined; + const absolute = path.resolve(filePath); + if (path.relative(outDir, absolute).startsWith('..')) return undefined; + + rewritten += 1; + return { content: rewriteDeclaration(absolute, content, sourceDirFor(absolute)) }; + }, + + afterBuild(emitted) { + // A hook that stopped being called must not read as "nothing to fix". + if (rewritten === 0) { + fail( + `no declaration file reached \`beforeWriteFile\` for \`${packageDir}\`. The hook is wired ` + + `up but never ran, so the emitted typings were not checked at all.` + ); + } + + const declarations = [...emitted.keys()].filter((file) => DECLARATION_FILE.test(file)); + if (declarations.length === 0) { + fail(`\`${outDir}\` received no declaration files, so there is nothing to assert about.`); + } + + const present = new Set([...emitted.keys()].map((file) => path.resolve(file))); + const findings: string[] = []; + + for (const declaration of declarations) { + const absolute = path.resolve(declaration); + const content = emitted.get(declaration)!; + for (const span of findModuleSpecifiers(absolute, content)) { + if (!isRelative(span.text)) continue; + if (!ALREADY_EXPLICIT.test(span.text)) { + findings.push(`${absolute}: \`${span.text}\` still carries no extension`); + continue; + } + // The specifier a `.d.ts` writes points at a `.js`; the file the + // compiler actually loads is the declaration beside it. + const target = path.resolve(path.dirname(absolute), span.text); + const candidates = [ + target.replace(/\.js$/, '.d.ts'), + target.replace(/\.mjs$/, '.d.mts'), + target.replace(/\.cjs$/, '.d.cts'), + ]; + if (/\.(js|mjs|cjs)$/.test(span.text) && !candidates.some((file) => present.has(file))) { + findings.push( + `${absolute}: \`${span.text}\` names no emitted declaration ` + + `(looked for ${candidates.filter((file, i, all) => all.indexOf(file) === i).join(', ')})` + ); + } + } + } + + if (findings.length > 0) { + fail( + `${findings.length} unresolvable relative specifier(s) survived into the emitted typings ` + + `of \`${packageDir}\`:\n ${findings.join('\n ')}` + ); + } + }, + }; +} From 75c51d5ae7164d7c02d29947d32fe961a119bd64 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:16:25 +0000 Subject: [PATCH 2/3] test(scripts): pin the dts extension rewriter; pin nodenext on two consumers Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- .changeset/dts-explicit-extensions-5365.md | 39 ++++ packages/app-shell/tsconfig.json | 12 +- packages/components/vite.config.ts | 2 +- packages/fields/tsconfig.json | 11 +- packages/layout/vite.config.ts | 2 +- .../vite-dts-explicit-extensions.test.ts | 206 ++++++++++++++++++ 6 files changed, 268 insertions(+), 4 deletions(-) create mode 100644 .changeset/dts-explicit-extensions-5365.md create mode 100644 scripts/__tests__/vite-dts-explicit-extensions.test.ts diff --git a/.changeset/dts-explicit-extensions-5365.md b/.changeset/dts-explicit-extensions-5365.md new file mode 100644 index 0000000000..fc7ca5aced --- /dev/null +++ b/.changeset/dts-explicit-extensions-5365.md @@ -0,0 +1,39 @@ +--- +'@object-ui/components': patch +'@object-ui/layout': patch +--- + +The typings both packages publish now carry an explicit extension on every relative specifier, so a consumer on `moduleResolution: nodenext` can follow them. + +`vite-plugin-dts` emits one declaration file per source file, and TypeScript +copies a module specifier into the declaration verbatim. `export * from './ui'` +therefore shipped extensionless in `dist/index.d.ts` — 21 such re-exports in +`@object-ui/components`, 7 in `@object-ui/layout`, 128 across the two emitted +trees. Node16/NodeNext resolution does not extension-search a relative +specifier, so the compiler could follow none of the hops and every symbol they +carried read as absent from the package: + +``` +error TS2305: Module '"@object-ui/components"' has no exported member 'Badge'. +``` + +Measured on `@object-ui/app-shell`, the largest consumer and the one that pulls +in both packages: 880 TS2305 across 162 files (864 from `components`, 16 from +`layout`), plus 215 TS7006 as fallout from the imports that stopped resolving. +On `@object-ui/fields`, 178 TS2305 and 57 TS7006. Both are zero now. + +The emitted `.js` never had the defect — rolldown resolves the same specifier +away — which is why `pnpm check:esm-specifiers`, whose verdict is about +specifier-preserving `.js` builds, correctly never scanned either package. The +fix is therefore in the declaration EMIT (`scripts/vite-dts-explicit-extensions.ts`, +shared by both `vite.config.ts` files), not in the sources: the same source line +produces a clean `.js` and a broken `.d.ts`, so no source edit can express the +difference. The rewriter resolves each specifier against the source tree the +output mirrors — a file hop becomes `./x.js`, a directory hop `./x/index.js` — +throws on anything it cannot resolve, and after the build re-parses the emitted +declarations to assert every relative specifier both carries an extension and +names a file the build really emitted. + +`packages/fields` and `packages/app-shell` take the `nodenext` pin as a result, +the same two lines `packages/react` has carried since objectui#4538, so the +property is enforced by the compiler on the consumer side rather than by review. diff --git a/packages/app-shell/tsconfig.json b/packages/app-shell/tsconfig.json index 446f3aeb1c..12e4b1f6e2 100644 --- a/packages/app-shell/tsconfig.json +++ b/packages/app-shell/tsconfig.json @@ -8,7 +8,17 @@ "types": ["node", "vite/client"], "noEmit": false, "declaration": true, - "composite": true + "composite": true, + + // See `packages/react/tsconfig.json` for the full argument: under + // `nodenext` a missing relative extension is TS2835 and a bare directory + // import is TS2834, so the property is enforced by the compiler instead + // of by review. objectui#5365 is what unblocked it here — this package + // pulls in both `@object-ui/components` and `@object-ui/layout`, and + // until their typings carried explicit extensions this pin produced + // 880 TS2305 across 162 files. + "module": "nodenext", + "moduleResolution": "nodenext" }, "include": ["src"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"], diff --git a/packages/components/vite.config.ts b/packages/components/vite.config.ts index 6df967b8c3..3621f85152 100644 --- a/packages/components/vite.config.ts +++ b/packages/components/vite.config.ts @@ -11,7 +11,7 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; -import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; export default defineConfig({ plugins: [ diff --git a/packages/fields/tsconfig.json b/packages/fields/tsconfig.json index 8e8c26971a..2a8827504b 100644 --- a/packages/fields/tsconfig.json +++ b/packages/fields/tsconfig.json @@ -13,7 +13,16 @@ // `noEmit: true`, so `tsc` here only CHECKS; `dist` is written by // vite-plugin-dts, which overrides `rootDir` to `src` and clears `paths`. "rootDir": "..", - "jsx": "react-jsx" + "jsx": "react-jsx", + + // See `packages/react/tsconfig.json` for the full argument: under + // `nodenext` a missing relative extension is TS2835 and a bare directory + // import is TS2834, so the property is enforced by the compiler instead + // of by review. objectui#5365 is what unblocked it here — until + // `@object-ui/components` emitted resolvable typings, this pin turned + // `tsc` red with 300+ TS2305 that had nothing to do with this package. + "module": "nodenext", + "moduleResolution": "nodenext" }, "include": ["src"], // Tests are excluded from the BUILD program so they stop being emitted into diff --git a/packages/layout/vite.config.ts b/packages/layout/vite.config.ts index a66b6facb5..a59521fc86 100644 --- a/packages/layout/vite.config.ts +++ b/packages/layout/vite.config.ts @@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react'; import dts from 'vite-plugin-dts'; import { resolve } from 'path'; -import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions'; +import { createDtsExplicitExtensions } from '../../scripts/vite-dts-explicit-extensions.ts'; export default defineConfig({ plugins: [ diff --git a/scripts/__tests__/vite-dts-explicit-extensions.test.ts b/scripts/__tests__/vite-dts-explicit-extensions.test.ts new file mode 100644 index 0000000000..f078e46f84 --- /dev/null +++ b/scripts/__tests__/vite-dts-explicit-extensions.test.ts @@ -0,0 +1,206 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + createDtsExplicitExtensions, + findModuleSpecifiers, + resolveExplicitSpecifier, + rewriteDeclaration, +} from '../vite-dts-explicit-extensions'; + +/** + * objectui#5365 — `@object-ui/components` and `@object-ui/layout` emit their + * typings from the BUNDLER half of a vite build, and `tsc` copies a module + * specifier into the declaration verbatim. So `export * from './ui'` shipped + * extensionless in `dist/index.d.ts` while the `.js` was clean (rolldown + * resolves the same specifier away), and every consumer on + * `moduleResolution: nodenext` read every named export of the package as + * missing — measured at 880 TS2305 on `@object-ui/app-shell`. + * + * What is pinned here, and why each case is not covered by the next: + * + * 1. **A file hop and a directory hop get different answers.** `./lib/utils` + * is `./lib/utils.js`; `./ui` — a directory with an `index.ts` — is + * `./ui/index.js`. A rewriter that appended `.js` to both would emit a + * specifier pointing at a file that does not exist, which is the same + * unresolvable state in a new spelling. + * 2. **Prose in JSDoc is not a hop.** `removeComments` is false in this + * repository's build configs, and its comments really do contain + * specifiers: `packages/app-shell/src/views/metadata-admin/i18n.ts` + * documents itself with `import { t } from './i18n'`. A text scan would + * rewrite those, and — when the prose names a path that does not exist — + * throw and break a correct build. This is why the implementation asks + * the TypeScript parser instead of a regular expression. + * 3. **An unresolvable specifier is LOUD.** The tempting fallback ("leave it + * alone") reproduces exactly the silence this card is about. + * 4. **A hook that never ran fails the build.** A wiring change that stops + * calling `beforeWriteFile` must not read as "nothing to fix". + * + * Every case builds its own fixture tree on disk, so nothing here depends on a + * prior `pnpm build` having happened. + */ + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, '..', '..'); + +/** A throwaway package tree: `/src/**` plus the `dist` path it maps to. */ +function fixture(files: Record): { packageDir: string; srcDir: string; outDir: string } { + const packageDir = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'dts-ext-')); + for (const [rel, body] of Object.entries(files)) { + const full = path.join(packageDir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, body); + } + return { packageDir, srcDir: path.join(packageDir, 'src'), outDir: path.join(packageDir, 'dist') }; +} + +describe('resolveExplicitSpecifier', () => { + it('maps a file hop to `.js` and a directory hop to `/index.js`', () => { + const { srcDir } = fixture({ + 'src/lib/utils.ts': 'export const cn = 1;\n', + 'src/ui/index.ts': 'export const Button = 1;\n', + 'src/widget.tsx': 'export const W = 1;\n', + }); + + expect(resolveExplicitSpecifier('./lib/utils', srcDir)).toBe('./lib/utils.js'); + expect(resolveExplicitSpecifier('./ui', srcDir)).toBe('./ui/index.js'); + expect(resolveExplicitSpecifier('./widget', srcDir)).toBe('./widget.js'); + }); + + it('leaves an already-explicit specifier alone', () => { + const { srcDir } = fixture({ 'src/lib/utils.ts': 'export const cn = 1;\n' }); + + expect(resolveExplicitSpecifier('./lib/utils.js', srcDir)).toBeNull(); + expect(resolveExplicitSpecifier('./index.css', srcDir)).toBeNull(); + }); + + it('returns null — not a guess — for a specifier naming nothing', () => { + const { srcDir } = fixture({ 'src/lib/utils.ts': 'export const cn = 1;\n' }); + + expect(resolveExplicitSpecifier('./lib/nope', srcDir)).toBeNull(); + }); +}); + +describe('rewriteDeclaration', () => { + it('rewrites every module-specifier form the emit can produce', () => { + const { srcDir, outDir } = fixture({ + 'src/lib/utils.ts': 'export const cn = 1;\n', + 'src/ui/index.ts': 'export const Button = 1;\n', + 'src/types.ts': 'export type T = 1;\n', + }); + + const declaration = [ + "export { cn } from './lib/utils';", + "export * from './ui';", + "import { Button } from './ui';", + 'export declare const x: import("./types").T;', + 'export declare const y: Promise;', + 'export { Button };', + '', + ].join('\n'); + + const out = rewriteDeclaration(path.join(outDir, 'index.d.ts'), declaration, srcDir); + + expect(out).toContain("from './lib/utils.js'"); + expect(out).toContain("export * from './ui/index.js'"); + expect(out).toContain("import { Button } from './ui/index.js'"); + expect(out).toContain('import("./types.js")'); + expect(out).toContain('import("./lib/utils.js")'); + // Nothing relative is left extensionless. + const leftover = findModuleSpecifiers('index.d.ts', out).filter( + (s) => /^\.\.?\//.test(s.text) && !/\.(js|mjs|cjs|json|css)$/.test(s.text) + ); + expect(leftover).toEqual([]); + }); + + it('does NOT touch a specifier that is only prose inside a comment', () => { + const { srcDir, outDir } = fixture({ 'src/i18n.ts': 'export const t = 1;\n' }); + + // The second path exists nowhere. A text scan would throw on it. + const declaration = [ + '/**', + " * Usage: `import { t } from './i18n'`", + " * Superseded by `from './does-not-exist'`.", + ' */', + "export { t } from './i18n';", + '', + ].join('\n'); + + const out = rewriteDeclaration(path.join(outDir, 'index.d.ts'), declaration, srcDir); + + expect(out).toContain("import { t } from './i18n'`"); + expect(out).toContain("from './does-not-exist'`"); + expect(out).toContain("export { t } from './i18n.js';"); + }); + + it('throws, naming the specifier, when a real hop cannot be resolved', () => { + const { srcDir, outDir } = fixture({ 'src/index.ts': 'export const a = 1;\n' }); + + expect(() => + rewriteDeclaration(path.join(outDir, 'index.d.ts'), "export * from './ghost';\n", srcDir) + ).toThrow(/\.\/ghost/); + }); +}); + +describe('createDtsExplicitExtensions', () => { + it('fails the build when no declaration file ever reached the hook', () => { + const { packageDir } = fixture({ 'src/index.ts': 'export const a = 1;\n' }); + const hooks = createDtsExplicitExtensions({ packageDir }); + + expect(() => hooks.afterBuild(new Map())).toThrow(/never ran/); + }); + + it('rejects an emitted specifier that names no emitted declaration', () => { + const { packageDir, outDir } = fixture({ + 'src/index.ts': "export * from './gone';\n", + 'src/gone.ts': 'export const a = 1;\n', + }); + const hooks = createDtsExplicitExtensions({ packageDir }); + + const written = hooks.beforeWriteFile( + path.join(outDir, 'index.d.ts'), + "export * from './gone';\n" + ); + expect(written?.content).toContain("'./gone.js'"); + + // `gone.d.ts` is deliberately absent from the emitted map: the rewrite was + // textually fine and the published typings would still be unfollowable. + expect(() => + hooks.afterBuild(new Map([[path.join(outDir, 'index.d.ts'), written!.content]])) + ).toThrow(/names no emitted declaration/); + + expect(() => + hooks.afterBuild( + new Map([ + [path.join(outDir, 'index.d.ts'), written!.content], + [path.join(outDir, 'gone.d.ts'), 'export declare const a = 1;\n'], + ]) + ) + ).not.toThrow(); + }); + + it('skips source maps and anything outside the declaration output', () => { + const { packageDir, outDir } = fixture({ 'src/index.ts': 'export const a = 1;\n' }); + const hooks = createDtsExplicitExtensions({ packageDir }); + + expect(hooks.beforeWriteFile(path.join(outDir, 'index.d.ts.map'), '{}')).toBeUndefined(); + expect(hooks.beforeWriteFile('/elsewhere/index.d.ts', "export * from './x';")).toBeUndefined(); + }); +}); + +describe('the two packages this fix exists for', () => { + it('wire the hook into their `vite-plugin-dts` invocation', () => { + for (const pkg of ['components', 'layout']) { + const config = fs.readFileSync( + path.join(repoRoot, 'packages', pkg, 'vite.config.ts'), + 'utf8' + ); + expect(config).toContain('vite-dts-explicit-extensions'); + expect(config).toContain('createDtsExplicitExtensions({ packageDir: __dirname })'); + } + }); +}); From 22b7d781c6e8135bb9fa50d17228b433054cd547 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 18:22:35 +0000 Subject: [PATCH 3/3] fix(fields): keep the nodenext pin here only; app-shell was the instrument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app-shell type-checks clean without the pin and shows 23 errors with it — 21 TS7006 traced to `@object-ui/plugin-chatbot`'s own extensionless typings (named re-exports degrade to `any` rather than going missing) and 2 TS2345 from `@monaco-editor/react` ESM interop. Neither is this card's defect. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RV6yuVCxymHYE16PL9vQkE --- .changeset/dts-explicit-extensions-5365.md | 8 +++++--- packages/app-shell/tsconfig.json | 12 +----------- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/.changeset/dts-explicit-extensions-5365.md b/.changeset/dts-explicit-extensions-5365.md index fc7ca5aced..73de8c9d34 100644 --- a/.changeset/dts-explicit-extensions-5365.md +++ b/.changeset/dts-explicit-extensions-5365.md @@ -34,6 +34,8 @@ throws on anything it cannot resolve, and after the build re-parses the emitted declarations to assert every relative specifier both carries an extension and names a file the build really emitted. -`packages/fields` and `packages/app-shell` take the `nodenext` pin as a result, -the same two lines `packages/react` has carried since objectui#4538, so the -property is enforced by the compiler on the consumer side rather than by review. +`packages/fields` takes the `nodenext` pin as a result — the same two lines +`packages/react` has carried since objectui#4538 — so the property is enforced by +the compiler on the consumer side rather than by review. `packages/app-shell` +does not: it type-checks clean without the pin and still shows 23 errors with it, +none of them from these two packages. That residue is filed separately. diff --git a/packages/app-shell/tsconfig.json b/packages/app-shell/tsconfig.json index 12e4b1f6e2..446f3aeb1c 100644 --- a/packages/app-shell/tsconfig.json +++ b/packages/app-shell/tsconfig.json @@ -8,17 +8,7 @@ "types": ["node", "vite/client"], "noEmit": false, "declaration": true, - "composite": true, - - // See `packages/react/tsconfig.json` for the full argument: under - // `nodenext` a missing relative extension is TS2835 and a bare directory - // import is TS2834, so the property is enforced by the compiler instead - // of by review. objectui#5365 is what unblocked it here — this package - // pulls in both `@object-ui/components` and `@object-ui/layout`, and - // until their typings carried explicit extensions this pin produced - // 880 TS2305 across 162 files. - "module": "nodenext", - "moduleResolution": "nodenext" + "composite": true }, "include": ["src"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"],