From 198c5c1af07cb2eef9395aa7e9c95014b98bd93c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:53:53 +0000 Subject: [PATCH 1/4] test(types): pin the ESM-only declared-leg defect and its verification surface (#14041) Red pre-fix by design: the rescue cases and the failure-kind split. Green pre-fix by design: the CJS-resolution precondition, the dual-build positive control, both strictly-tighter pins, and both case-(a) pins. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP --- packages/types/src/node.test.ts | 298 ++++++++++++++++++++++++++++++++ 1 file changed, 298 insertions(+) diff --git a/packages/types/src/node.test.ts b/packages/types/src/node.test.ts index 4711dd0f82..a264832283 100644 --- a/packages/types/src/node.test.ts +++ b/packages/types/src/node.test.ts @@ -822,3 +822,301 @@ exports.BUILD = 'cjs'; expect((await importer(root)(`${SUBPATHS}/deep/leaf`)).WHERE).toBe('leaf-esm'); }); }); + +/** + * #14041 — the declared leg could not load an ESM-only package AT ALL, and + * misreported it as a broken install. + * + * `hostRequire.resolve(pkg)` is a CommonJS resolution. A package publishing + * only an `import` condition — common outside this workspace, where every + * dual build publishes both — makes that resolve throw + * `ERR_PACKAGE_PATH_NOT_EXPORTED`, and the leg classified EVERY resolver + * throw as `declared-unresolvable`: an INSTALL-problem message about an + * install that is fine. Nothing the message told the operator to do could + * help. + * + * The fix is a second finder that fires ONLY when the CJS resolve throws: a + * `node_modules` lookup anchored at `hostRoot` — strictly TIGHTER than CJS + * resolution (one directory, no `NODE_PATH`, no walk above `hostRoot`), so it + * cannot reopen the #4719 hole. Two cases below pin exactly that tightness on + * layouts CJS resolution CAN see: where the fixture and the security gate + * disagree, the gate wins and the load stays refused. + * + * ⛔ No workspace package can exercise this defect — every dual build here + * publishes both conditions (`check:dual-build-cjs-loads` measures all of + * them loading) — so these fixtures are the repo's ONLY detection surface. + * A green run of every other suite proves nothing about this leg. + */ +describe('the declared leg loads an ESM-only package via a hostRoot node_modules walk (#14041)', () => { + /** The card's exact shape: `import` condition only, no `require`, no `main`. */ + const ESM_ONLY = '@fixture/esm-only'; + /** ESM-only with a subpath map — the walk must answer subpaths too. */ + const ESM_SUBPATHS = '@fixture/esm-only-subpaths'; + /** Publishes NO runtime entry at all — the failure-kind split's case (b). */ + const TYPES_ONLY = '@fixture/types-only'; + /** Names an `import` target that does not exist on disk — still case (a). */ + const ESM_UNBUILT = '@fixture/esm-only-unbuilt'; + /** Dual build — the positive control: never reaches the fallback at all. */ + const DUAL_CONTROL = '@fixture/dual-still-cjs-found'; + /** ESM-only, installed ONLY in the NODE_PATH store — must NOT be rescued. */ + const HOISTED_ESM_ONLY = '@fixture/esm-only-hoisted'; + /** ESM-only, installed only ABOVE the host root — must NOT be rescued. */ + const PARENT_ESM_ONLY = '@fixture/esm-only-parent'; + /** ESM-only whose entry THROWS while evaluating — a crash, not an absence. */ + const ESM_THROWS = '@fixture/esm-only-throws'; + + const roots: string[] = []; + + function writeShapedPackage( + base: string, + name: string, + manifest: Record, + files: Record, + ): void { + const dir = join(base, 'node_modules', ...name.split('/')); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, 'package.json'), + JSON.stringify({ name, version: '0.0.0-fixture', type: 'module', ...manifest }), + 'utf8', + ); + for (const rel of Object.keys(files)) { + const target = join(dir, rel); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, files[rel] as string, 'utf8'); + } + } + + /** A fresh host app per case — fresh URLs, so no case answers another's load. */ + function app(tag: string, dependencies: Record): string { + const root = mkdtempSync(join(tmpdir(), `os-esm-only-${tag}-`)); + roots.push(root); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'esm-only-host-fixture', type: 'module', dependencies }), + 'utf8', + ); + return root; + } + + const ESM_ONLY_EXPORTS = { '.': { import: './dist/index.js' } }; + + afterAll(() => { + for (const dir of roots) rmSync(dir, { recursive: true, force: true }); + }); + + it('PRECONDITION: CJS resolution cannot see an ESM-only package — the fallback is additive', () => { + // The cause, pinned on its own. `hostRequire.resolve` answers the `require` + // condition; this exports map names none, so the resolve THROWS — which is + // why every case this suite rescues was a hard failure before the fix, and + // why the fallback cannot change any currently-succeeding load: it runs + // only inside this throw's catch. + const root = app('precondition', { [ESM_ONLY]: '1' }); + writeShapedPackage(root, ESM_ONLY, { exports: ESM_ONLY_EXPORTS }, { + 'dist/index.js': "export const BUILD = 'esm-only';\n", + }); + let code: string | undefined; + try { + createHostRequire(root).resolve(ESM_ONLY); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe('ERR_PACKAGE_PATH_NOT_EXPORTED'); + }); + + it('loads a declared ESM-only package — the card', async () => { + // Before the fix: MODULE_NOT_FOUND, worded as an INSTALL problem, about an + // install that is fine. + const root = app('loads', { [ESM_ONLY]: '1' }); + writeShapedPackage(root, ESM_ONLY, { exports: ESM_ONLY_EXPORTS }, { + 'dist/index.js': "export const BUILD = 'esm-only';\n", + }); + expect((await createHostImporter(root)(ESM_ONLY)).BUILD).toBe('esm-only'); + }); + + it('loads a declared ESM-only SUBPATH', async () => { + const root = app('subpath', { [ESM_SUBPATHS]: '1' }); + writeShapedPackage( + root, + ESM_SUBPATHS, + { + exports: { + '.': { import: './dist/index.js' }, + './plugin': { import: './dist/plugin.js' }, + './deep/*': { import: './dist/deep/*.js' }, + }, + }, + { + 'dist/index.js': "export const WHERE = 'root';\n", + 'dist/plugin.js': "export const WHERE = 'plugin';\n", + 'dist/deep/leaf.js': "export const WHERE = 'leaf';\n", + }, + ); + expect((await createHostImporter(root)(`${ESM_SUBPATHS}/plugin`)).WHERE).toBe('plugin'); + expect((await createHostImporter(root)(`${ESM_SUBPATHS}/deep/leaf`)).WHERE).toBe('leaf'); + }); + + it('POSITIVE CONTROL: a dual-published package never reaches the fallback, and loads as before', async () => { + // The strictly-additive claim's control. The CJS finder still answers for + // every package that publishes a `require` condition — the resolve + // SUCCEEDS, so the new code is structurally unreachable — and the load + // still selects the `import` build exactly as #13330 pinned. + const root = app('control', { [DUAL_CONTROL]: '1' }); + writeShapedPackage( + root, + DUAL_CONTROL, + { + main: 'dist/index.cjs', + exports: { '.': { import: './dist/index.js', require: './dist/index.cjs' } }, + }, + { + 'dist/index.js': "export const BUILD = 'esm';\n", + 'dist/index.cjs': "exports.BUILD = 'cjs';\n", + }, + ); + expect(createHostRequire(root).resolve(DUAL_CONTROL)).toMatch(/dist[/\\]index\.cjs$/); + expect((await createHostImporter(root)(DUAL_CONTROL)).BUILD).toBe('esm'); + }); + + it('TIGHTNESS: an ESM-only package reachable only through NODE_PATH is NOT rescued (#4719)', async () => { + // The walk is strictly tighter than the CJS resolution it backs up. This + // package sits in the NODE_PATH store — where CJS resolution demonstrably + // reaches it (the resolve fails on the CONDITION, not on finding it) — and + // the host app declares it. A finder that honoured NODE_PATH would load + // it; the declaration gate's whole point (#4719) is that it must not. + // The store is `nodePathStore` from the file-level fixture. A NODE_PATH + // entry IS a node_modules-shaped directory — packages sit directly inside + // it (`/`), so the host-app helper (which inserts a + // `node_modules` segment) does not apply here. + const storeDir = join(nodePathStore, ...HOISTED_ESM_ONLY.split('/')); + mkdirSync(join(storeDir, 'dist'), { recursive: true }); + writeFileSync( + join(storeDir, 'package.json'), + JSON.stringify({ + name: HOISTED_ESM_ONLY, + version: '0.0.0-fixture', + type: 'module', + exports: ESM_ONLY_EXPORTS, + }), + 'utf8', + ); + writeFileSync( + join(storeDir, 'dist', 'index.js'), + "export const BUILD = 'esm-only-hoisted';\n", + 'utf8', + ); + const root = app('node-path', { [HOISTED_ESM_ONLY]: '1' }); + let code: string | undefined; + try { + createHostRequire(root).resolve(HOISTED_ESM_ONLY); + } catch (e) { + code = (e as { code?: string }).code; + } + // CJS resolution FOUND it (condition refused, not absence) — so a refusal + // below is the tightness working, not blindness. + expect(code).toBe('ERR_PACKAGE_PATH_NOT_EXPORTED'); + const err = await createHostImporter(root)(HOISTED_ESM_ONLY).catch((e: unknown) => e); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + expect((err as Error).message).toMatch(/INSTALL problem/); + }); + + it('TIGHTNESS: an ESM-only package installed ABOVE hostRoot is NOT rescued', async () => { + // Same shape, the other looseness: CJS resolution walks every parent + // directory's node_modules; the fallback must not walk above hostRoot. + const parent = mkdtempSync(join(tmpdir(), 'os-esm-only-parent-')); + roots.push(parent); + writeShapedPackage(parent, PARENT_ESM_ONLY, { exports: ESM_ONLY_EXPORTS }, { + 'dist/index.js': "export const BUILD = 'esm-only-parent';\n", + }); + const root = join(parent, 'app'); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ + name: 'nested-host-fixture', + type: 'module', + dependencies: { [PARENT_ESM_ONLY]: '1' }, + }), + 'utf8', + ); + let code: string | undefined; + try { + createHostRequire(root).resolve(PARENT_ESM_ONLY); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe('ERR_PACKAGE_PATH_NOT_EXPORTED'); + const err = await createHostImporter(root)(PARENT_ESM_ONLY).catch((e: unknown) => e); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + }); + + it('case (a): declared and installed NOWHERE keeps the INSTALL message, verbatim class', async () => { + // The rescue must not soften the genuinely-broken install. Same wording, + // same kind, same classification as before the fix. + const root = app('not-installed', { '@fixture/esm-only-never-installed': '1' }); + const err = await createHostImporter(root)('@fixture/esm-only-never-installed').catch( + (e: unknown) => e, + ); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + expect((err as Error).message).toMatch(/INSTALL problem, not a declaration problem/); + expect((err as { code?: string }).code).toBe('MODULE_NOT_FOUND'); + }); + + it('case (a) boundary: an `import` target named but MISSING on disk stays an INSTALL problem', async () => { + // The split's boundary criterion: (b) fires only when the manifest names + // NOTHING loadable. Here the manifest names `./dist/index.js` and the file + // is absent — a dist that was never built or published, which is exactly + // the INSTALL message's third bullet. Rescuing the message here would + // trade a right verdict for a wrong one, in the other direction. + const root = app('unbuilt', { [ESM_UNBUILT]: '1' }); + writeShapedPackage(root, ESM_UNBUILT, { exports: ESM_ONLY_EXPORTS }, {}); + const err = await createHostImporter(root)(ESM_UNBUILT).catch((e: unknown) => e); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + expect((err as Error).message).toMatch(/INSTALL problem/); + }); + + it('case (b): installed fine but publishing NO loadable entry gets a message about the PACKAGE', async () => { + // The failure-kind split. The app declared it, the install delivered it, + // and the package's exports names no runtime entry for ANY condition this + // loader can use — `pnpm install` will never change that, so the INSTALL + // message is a wrong verdict and the remedy lives in the package itself. + const root = app('types-only', { [TYPES_ONLY]: '1' }); + writeShapedPackage(root, TYPES_ONLY, { exports: { '.': { types: './dist/index.d.ts' } } }, { + 'dist/index.d.ts': 'export declare const BUILD: string;\n', + }); + const err = await createHostImporter(root)(TYPES_ONLY).catch((e: unknown) => e); + expect(hostImportFailureKind(err)).toBe('declared-no-loadable-entry'); + expect((err as Error).message).toMatch(/publishes no entry/); + expect((err as Error).message).toMatch(/PACKAGE/); + expect((err as Error).message).not.toMatch(/INSTALL problem/); + expect((err as Error).message).not.toMatch(/does not declare it/); + // Still the "missing" class for every existing caller's classifier. + expect((err as { code?: string }).code).toBe('MODULE_NOT_FOUND'); + }); + + it('case (b): a subpath the exports map never names is the package shape too', async () => { + const root = app('no-subpath', { [ESM_ONLY]: '1' }); + writeShapedPackage(root, ESM_ONLY, { exports: ESM_ONLY_EXPORTS }, { + 'dist/index.js': "export const BUILD = 'esm-only';\n", + }); + const err = await createHostImporter(root)(`${ESM_ONLY}/not-exported`).catch( + (e: unknown) => e, + ); + expect(hostImportFailureKind(err)).toBe('declared-no-loadable-entry'); + expect((err as Error).message).not.toMatch(/INSTALL problem/); + }); + + it('an evaluation crash in a rescued entry propagates untouched, as everywhere else', async () => { + // The contract the whole file keeps: resolution failure is the only thing + // that falls back or gets classified; a package that LOADS and explodes is + // a crash, and masking it as module-not-found is the defect class this + // helper exists to remove. + const root = app('throws', { [ESM_THROWS]: '1' }); + writeShapedPackage(root, ESM_THROWS, { exports: ESM_ONLY_EXPORTS }, { + 'dist/index.js': 'throw new Error("esm-only fixture exploded on import");\n', + }); + const err = await createHostImporter(root)(ESM_THROWS).catch((e: unknown) => e); + expect((err as Error).message).toMatch(/esm-only fixture exploded on import/); + expect(hostImportFailureKind(err)).toBeUndefined(); + }); +}); From 2f908a1a68c40033c4ae4e6dad8711529716034b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:57:26 +0000 Subject: [PATCH 2/4] fix(types): load a declared ESM-only host package via a strictly-tighter hostRoot node_modules walk (#14041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declared leg's finder is hostRequire.resolve — a CJS resolution. A package publishing only an import condition made it throw ERR_PACKAGE_PATH_NOT_EXPORTED, and every resolver throw was classified declared-unresolvable: an INSTALL message about an install that is fine. The fallback fires only inside that catch (a hard failure before, so strictly additive) and consults exactly one directory — hostRoot/node_modules/ — no NODE_PATH, no walk above hostRoot, no bare require: strictly tighter than the CJS resolution it backs up, so it cannot reopen the #4719 declaration-gate hole. The failure kind splits on whether any install action can help: a manifest naming a runtime target whose file is missing keeps the INSTALL wording; a manifest naming nothing loadable (types-only, browser-only, unexported subpath) is declared-no-loadable-entry with a message about the package's own shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP --- packages/types/src/node.ts | 264 ++++++++++++++++++++++++++++++++++--- 1 file changed, 248 insertions(+), 16 deletions(-) diff --git a/packages/types/src/node.ts b/packages/types/src/node.ts index a9d115355d..7e45822d88 100644 --- a/packages/types/src/node.ts +++ b/packages/types/src/node.ts @@ -95,12 +95,18 @@ * broken/pruned/unbuilt. Remedy: fix the install. Re-reading the * `package.json` is wasted effort; the declaration is right there. * + * #14041 adds a third, split OUT of the second: **declared, installed, and the + * package publishes no entry Node can load** — a shape problem in the package + * itself, which no install action can ever fix (the `HostImportFailureKind` + * doc carries the split; the "#14041" section note below carries the finder + * that makes an ESM-only publish load instead of failing at all). + * * {@link hostImportFailureKind} exposes that classification to callers so their * fail-fast text can say which one it is (`packages/cli` ADR-0093 D5, * `packages/verify` `bootStack`, `packages/qa/dogfood`'s enterprise probe). */ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, realpathSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join, resolve, sep } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -288,10 +294,22 @@ export function isDeclaredByHost(specifier: string, hostRoot?: string): boolean * in the app and install. * - `declared-unresolvable` — the app declares it and it still would not * resolve. Remedy: fix the INSTALL. Re-reading the manifest is wasted effort. - * - * An evaluation crash is neither: it propagates untouched and carries no kind. + * - `declared-no-loadable-entry` (#14041) — the app declares it, the install + * delivered it, and the package's own `exports` names NO entry Node can load + * for the requested subpath — no `require`-condition target (which is why the + * CJS resolution refused) and no `import`-condition one for the fallback + * either (a `types`-only or `browser`-only publish, or a subpath the map + * never names). Remedy: change the PACKAGE — neither the app's manifest nor + * its install can ever fix this, which is exactly why it must not share the + * `declared-unresolvable` INSTALL wording. + * + * An evaluation crash is none of these: it propagates untouched and carries no + * kind. */ -export type HostImportFailureKind = 'undeclared' | 'declared-unresolvable'; +export type HostImportFailureKind = + | 'undeclared' + | 'declared-unresolvable' + | 'declared-no-loadable-entry'; /** * Property carrying {@link HostImportFailureKind} on a thrown error. @@ -306,7 +324,11 @@ export const HOST_IMPORT_FAILURE_KIND = 'objectstackHostImportFailureKind'; /** The classification on an error thrown by a {@link HostImporter}, if any. */ export function hostImportFailureKind(err: unknown): HostImportFailureKind | undefined { const kind = (err as Record | null | undefined)?.[HOST_IMPORT_FAILURE_KIND]; - return kind === 'undeclared' || kind === 'declared-unresolvable' ? kind : undefined; + return kind === 'undeclared' || + kind === 'declared-unresolvable' || + kind === 'declared-no-loadable-entry' + ? kind + : undefined; } function hostImportError( @@ -464,25 +486,41 @@ const ESM_IMPORT_CONDITIONS: ReadonlySet = new Set([ ]); /** - * Pick a target from one `exports` node under {@link ESM_IMPORT_CONDITIONS}. + * The conditions a CommonJS `require()` matches — what `hostRequire.resolve` + * itself answers. Used by the #14041 failure-kind split ONLY as a manifest + * READ, never as a second resolution: when the CJS resolver has already + * thrown, "does the map name a `require`-condition target at all?" is what + * separates a broken install (it names one, the files are missing) from a + * package that publishes no CommonJS entry in the first place. + */ +const CJS_REQUIRE_CONDITIONS: ReadonlySet = new Set([ + 'node-addons', + 'node', + 'require', + 'default', +]); + +/** + * Pick a target from one `exports` node under the given active conditions + * (membership, not priority — see {@link ESM_IMPORT_CONDITIONS}). * * A string is a target; an array is a fallback list (first resolvable wins); * `null` blocks the subpath; an object is a condition map. Nesting is arbitrary * (`{ import: { types: …, default: … } }` is the shape `tsup` emits). */ -function selectImportTarget(node: unknown): string | undefined { +function selectConditionTarget(node: unknown, conditions: ReadonlySet): string | undefined { if (typeof node === 'string') return node; if (Array.isArray(node)) { for (const alternative of node) { - const hit = selectImportTarget(alternative); + const hit = selectConditionTarget(alternative, conditions); if (hit !== undefined) return hit; } return undefined; } if (node === null || typeof node !== 'object') return undefined; for (const entry of Object.entries(node as Record)) { - if (!ESM_IMPORT_CONDITIONS.has(entry[0])) continue; - const hit = selectImportTarget(entry[1]); + if (!conditions.has(entry[0])) continue; + const hit = selectConditionTarget(entry[1], conditions); if (hit !== undefined) return hit; } return undefined; @@ -497,7 +535,11 @@ function selectImportTarget(node: unknown): string | undefined { * test Node applies, and the reason `{ "import": …, "require": … }` needs no * special case here. */ -function resolveExportsSubpath(exportsField: unknown, subpath: string): string | undefined { +function resolveExportsSubpath( + exportsField: unknown, + subpath: string, + conditions: ReadonlySet = ESM_IMPORT_CONDITIONS, +): string | undefined { if (exportsField === undefined) return undefined; const keys = @@ -507,10 +549,14 @@ function resolveExportsSubpath(exportsField: unknown, subpath: string): string | const isSubpathMap = keys !== undefined && keys.length > 0 && keys.every((key) => key === '.' || key.indexOf('./') === 0); - if (!isSubpathMap) return subpath === '.' ? selectImportTarget(exportsField) : undefined; + if (!isSubpathMap) { + return subpath === '.' ? selectConditionTarget(exportsField, conditions) : undefined; + } const map = exportsField as Record; - if (Object.prototype.hasOwnProperty.call(map, subpath)) return selectImportTarget(map[subpath]); + if (Object.prototype.hasOwnProperty.call(map, subpath)) { + return selectConditionTarget(map[subpath], conditions); + } // Pattern keys (`"./*": "./dist/*.js"`). Node takes the key with the longest // static prefix, breaking ties on the longest suffix, and substitutes the @@ -535,10 +581,15 @@ function resolveExportsSubpath(exportsField: unknown, subpath: string): string | } if (best === undefined) return undefined; const matched = subpath.slice(best.prefix.length, subpath.length - best.suffix.length); - const target = selectImportTarget(best.target); + const target = selectConditionTarget(best.target, conditions); return target === undefined ? undefined : target.split('*').join(matched); } +/** The `exports` subpath a specifier addresses (`.`, `./plugin`, `./deep/x`). */ +function exportsSubpathOf(specifier: string, packageName: string): string { + return specifier === packageName ? '.' : `.${specifier.slice(packageName.length)}`; +} + /** * The directory of the package named `packageName` that owns `resolvedFile`. * @@ -599,8 +650,7 @@ function esmEntryForDeclared( // package publishes and CJS resolution already returned it. if (exportsField === undefined || exportsField === null) return undefined; - const subpath = - specifier === packageName ? '.' : `.${specifier.slice(packageName.length)}`; + const subpath = exportsSubpathOf(specifier, packageName); const target = resolveExportsSubpath(exportsField, subpath); if (typeof target !== 'string' || target.indexOf('./') !== 0) return undefined; @@ -610,6 +660,167 @@ function esmEntryForDeclared( return existsSync(entry) ? entry : undefined; } +/** + * ── #14041: an ESM-only package needs a finder the CJS resolver is not ─────── + * + * The #13330 note above re-decides the CONDITION for a package the CJS + * resolver already LOCATED. A package publishing only an `import` condition — + * `{"exports": {".": {"import": "./dist/index.js"}}}`, ordinary outside this + * workspace — never gets that far: `hostRequire.resolve` throws + * `ERR_PACKAGE_PATH_NOT_EXPORTED`, and the declared leg classified EVERY + * resolver throw as `declared-unresolvable` — an INSTALL-problem message about + * an install that is fine, prescribing remedies (`pnpm install`, un-prune, + * rebuild) none of which can ever help. + * + * The fallback finder is a `node_modules` lookup anchored at `hostRoot`, and + * it is deliberately STRICTLY TIGHTER than the CJS resolution it backs up: + * + * - ONE directory — `/node_modules/` — the single place a + * dependency the host declares and installs must physically appear; + * - no `NODE_PATH` (the #4719 hole; honouring it here would reopen the + * declaration gate from the fallback side); + * - no walk above `hostRoot` (CJS resolution climbs every parent's + * `node_modules`; a package that exists only up there is someone else's); + * - no bare `require`/`import` of the specifier (a second resolver would + * re-import every looseness one call at a time). + * + * `import.meta.resolve` with a parent URL is NOT the mechanism, on the same + * measurement the #10943 note below records: without + * `--experimental-import-meta-resolve` the parent argument is SILENTLY + * IGNORED, so it answers from the WRONG base with full confidence — the exact + * failure class this card removes. + * + * It fires ONLY inside `hostRequire.resolve`'s catch — a path that was a hard + * failure before — so no currently-succeeding load can change behaviour. + * + * When even this finder cannot produce an entry, the failure KIND is split on + * one criterion: **can any install action ever help?** + * + * - the package is not in the host's `node_modules`, or its manifest NAMES a + * runtime target whose file is missing (a dist never built, a partial + * publish) → `declared-unresolvable`, the existing INSTALL wording, + * unchanged — it is right for both; + * - the package is installed and its manifest names NO runtime entry for the + * requested subpath under either the `require` or the `import` conditions + * (`types`-only, `browser`-only, an unexported subpath) → + * `declared-no-loadable-entry`, a message about the PACKAGE's own shape — + * no edit to the app and no install action can change what the package + * publishes. + */ +type DeclaredCjsResolveFallback = + /** Not present in the host's own `node_modules` — the install really is the problem. */ + | { outcome: 'absent' } + /** Rescued: the `import`-condition entry to load. */ + | { outcome: 'entry'; entry: string } + /** Present, and its manifest names a runtime target — the FILES are the problem. */ + | { outcome: 'install-broken' } + /** Present, and its manifest names nothing loadable — the PACKAGE is the problem. */ + | { outcome: 'no-loadable-entry'; packageDir: string }; + +/** + * The one directory the fallback finder consults, verified to hold the + * declared package (a `package.json` whose `name` matches) and then + * realpath'd — under pnpm the link target is + * `.pnpm/@/node_modules/`, the directory the package's own + * transitive imports resolve against, exactly as the CJS resolver's realpath + * answer behaves on the succeeding path. + */ +function hostInstalledPackageDir(packageName: string, hostRoot: string): string | undefined { + const linked = join(hostRoot, 'node_modules', ...packageName.split('/')); + try { + const manifest = JSON.parse(readFileSync(join(linked, 'package.json'), 'utf8')) as { + name?: unknown; + }; + if (manifest.name !== packageName) return undefined; + } catch { + return undefined; + } + try { + return realpathSync(linked); + } catch { + // The manifest read above already succeeded through this path; an exotic + // realpath failure does not un-install the package. + return linked; + } +} + +/** The #14041 fallback: see the section note above for the shape and the split. */ +function declaredCjsResolveFallback( + specifier: string, + packageName: string, + hostRoot: string, +): DeclaredCjsResolveFallback { + const packageDir = hostInstalledPackageDir(packageName, hostRoot); + if (packageDir === undefined) return { outcome: 'absent' }; + + let exportsField: unknown; + try { + exportsField = ( + JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { exports?: unknown } + ).exports; + } catch { + return { outcome: 'absent' }; + } + // No `exports` map ⇒ CJS resolution already tried everything such a package + // publishes (`main`, the index files) and still threw: missing files. + if (exportsField === undefined || exportsField === null) return { outcome: 'install-broken' }; + + const subpath = exportsSubpathOf(specifier, packageName); + + const importTarget = resolveExportsSubpath(exportsField, subpath, ESM_IMPORT_CONDITIONS); + if (typeof importTarget === 'string' && importTarget.indexOf('./') === 0) { + const entry = resolve(packageDir, importTarget); + // Node refuses an exports target that escapes its package; so does this. + if (entry.indexOf(packageDir + sep) === 0 && existsSync(entry)) { + return { outcome: 'entry', entry }; + } + // The manifest names an `import` target and the file is not there — a + // dist never built or a partial publish. An install/build problem, with + // the existing wording's remedies intact. + return { outcome: 'install-broken' }; + } + + const requireTarget = resolveExportsSubpath(exportsField, subpath, CJS_REQUIRE_CONDITIONS); + if (typeof requireTarget === 'string' && requireTarget.indexOf('./') === 0) { + // The package DOES publish a CommonJS entry for this subpath; the CJS + // resolver threw over the files behind it, not over the shape. + return { outcome: 'install-broken' }; + } + + return { outcome: 'no-loadable-entry', packageDir }; +} + +function noLoadableEntryMessage( + declaration: HostDeclaration, + packageDir: string, + subpath: string, + cause: unknown, +): string { + const { packageName, hostRoot, field, specifier } = declaration; + const detail = cause instanceof Error ? cause.message : String(cause); + const subpathNote = subpath === '.' ? 'its main entry (".")' : `the subpath '${subpath}'`; + return ( + `Cannot load module '${packageName}': the host app DECLARES it ` + + `(${field}: ${JSON.stringify(specifier)}) and it IS installed, but the package ` + + 'publishes no entry that Node can load.\n' + + ` host app: ${hostRoot}\n` + + ` installed at: ${packageDir}\n` + + "\n This is a problem with the PACKAGE's own published shape, not with the app or\n" + + ' its install — the declaration is right and the package is on disk, so neither\n' + + ' re-reading package.json nor re-running `pnpm install` can change anything.\n' + + ' Measured from its manifest:\n' + + ` • its "exports" map names no \`require\`-condition entry for ${subpathNote},\n` + + ' so a CommonJS resolution cannot see it at all\n' + + ' • and no `import`-condition entry either, so there is nothing for the ESM\n' + + ' fallback to load\n' + + ' The remedy lives in the package: it must publish a runtime entry for this\n' + + ' subpath (an `import` condition suffices here; a dual build adds `require`).\n' + + ' A publish carrying only `types` / `browser`-style conditions cannot be loaded\n' + + ' by a Node host at all.\n' + + ` (resolver: ${detail})` + ); +} + /** * Build an importer that loads a package **as the host app declares it**, and * otherwise falls back to the importing package's own resolution. @@ -714,6 +925,27 @@ export function createHostImporter( try { resolved = hostRequire.resolve(pkg); } catch (cause) { + // #14041: the CJS resolver cannot see an ESM-only publish at all. Try + // the strictly-tighter hostRoot node_modules finder before concluding + // anything — this catch was a hard failure before, so the fallback is + // strictly additive — and when it cannot help either, report the kind + // the walk actually measured (see the #14041 section note). + const fallback = declaredCjsResolveFallback(pkg, declaration.packageName, hostRoot); + if (fallback.outcome === 'entry') { + return import(pathToFileURL(fallback.entry).href); + } + if (fallback.outcome === 'no-loadable-entry') { + throw hostImportError( + 'declared-no-loadable-entry', + noLoadableEntryMessage( + declaration, + fallback.packageDir, + exportsSubpathOf(pkg, declaration.packageName), + cause, + ), + cause, + ); + } throw hostImportError( 'declared-unresolvable', unresolvableMessage(declaration, cause), From 69e617764e00f70f3f227f5ba595bbf16949b58f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 18:58:52 +0000 Subject: [PATCH 3/4] chore: changeset for #14041 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP --- .changeset/host-importer-esm-only-walk.md | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .changeset/host-importer-esm-only-walk.md diff --git a/.changeset/host-importer-esm-only-walk.md b/.changeset/host-importer-esm-only-walk.md new file mode 100644 index 0000000000..4667fa7e64 --- /dev/null +++ b/.changeset/host-importer-esm-only-walk.md @@ -0,0 +1,42 @@ +--- +"@objectstack/types": minor +--- + +fix(types): load a declared ESM-only host package through `createHostImporter`, and split its failure kind (#14041) + +The declared leg's finder is `hostRequire.resolve(pkg)` — a **CommonJS** +resolution. A host-app package publishing only an `import` condition +(`{"exports": {".": {"import": "./dist/index.js"}}}`, ordinary for pure-ESM +publishes outside this workspace) made that resolve throw +`ERR_PACKAGE_PATH_NOT_EXPORTED`, and the leg classified **every** resolver +throw as `declared-unresolvable`: the load hard-failed, worded as an INSTALL +problem — `pnpm install`, un-prune, rebuild — about an install that was fine. +Nothing the message prescribed could help. + +**The finder.** When — and only when — `hostRequire.resolve` throws, the leg +now consults exactly one directory: `/node_modules/` (name +verified against the package's own manifest, then realpath'd, so its +transitive imports resolve from its real location exactly as on the succeeding +path). If that package's `exports` names an existing `import`-condition target +for the requested subpath, it is imported. The fallback is **strictly +tighter** than the CJS resolution it backs up — no `NODE_PATH`, no walk above +`hostRoot`, no bare `require` — so it cannot reopen the #4719 declaration-gate +hole: a package reachable only through a hoisted store or a parent directory +stays refused, even though CJS resolution can see it there. And because it +runs only inside a catch that was a hard failure before, no +currently-succeeding load changes behaviour. + +**The split.** When the fallback cannot help either, the failure kind is +decided by whether any install action could: a package absent from the host's +`node_modules`, or one whose manifest names a runtime target whose file is +missing (a dist never built, a partial publish), keeps `declared-unresolvable` +and the existing INSTALL wording — it is right for both. A package that is +installed and whose manifest names **no** runtime entry for the subpath under +either the `require` or the `import` conditions (a `types`-only or +`browser`-only publish, an unexported subpath) now fails as the new +`HostImportFailureKind` value **`declared-no-loadable-entry`**, with a message +about the package's own published shape — the remedy lives in the package, +and an operator is no longer sent to re-run `pnpm install` against a correct +install. The new error still carries `code: 'MODULE_NOT_FOUND'`, so every +existing caller's missing-vs-crashed classification is unchanged, and an +evaluation crash still propagates untouched with no kind. From 3728813b79ea2819c65af3feaa6d42f164b0a4e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 19:55:40 +0000 Subject: [PATCH 4/4] fix(types): mirror Node's invalid-segment refusal before exports resolution in the #14041 fallback (#14271 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback received the specifier unvalidated by any real resolver — unlike the #13330 path, where hostRequire.resolve had already validated it — so a pattern key could substitute a traversal span (../..) into its target and load a package's non-exported internal file, on ESM-only and dual-published packages alike. A subpath whose segments include '', '.', '..' or node_modules (case-insensitive) is now refused before exports resolution, keeping exactly the hard failure and kind these specifiers get on main. Pins: both traversal shapes (with the real resolver's refusal asserted first), plus the previously-unpinned manifest-name and escape-containment checks. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP --- .changeset/host-importer-esm-only-walk.md | 20 +++- packages/types/src/node.test.ts | 123 ++++++++++++++++++++++ packages/types/src/node.ts | 53 +++++++++- 3 files changed, 189 insertions(+), 7 deletions(-) diff --git a/.changeset/host-importer-esm-only-walk.md b/.changeset/host-importer-esm-only-walk.md index 4667fa7e64..37596a0aa9 100644 --- a/.changeset/host-importer-esm-only-walk.md +++ b/.changeset/host-importer-esm-only-walk.md @@ -20,11 +20,16 @@ transitive imports resolve from its real location exactly as on the succeeding path). If that package's `exports` names an existing `import`-condition target for the requested subpath, it is imported. The fallback is **strictly tighter** than the CJS resolution it backs up — no `NODE_PATH`, no walk above -`hostRoot`, no bare `require` — so it cannot reopen the #4719 declaration-gate -hole: a package reachable only through a hoisted store or a parent directory -stays refused, even though CJS resolution can see it there. And because it -runs only inside a catch that was a hard failure before, no -currently-succeeding load changes behaviour. +`hostRoot`, no bare `require`, and Node's invalid-segment refusal mirrored +before exports resolution (a subpath carrying `''`, `.`, `..` or +`node_modules` segments is refused exactly as both of Node's resolvers refuse +it, so a pattern key can never substitute a traversal span into its target) — +so it cannot reopen the #4719 declaration-gate hole and cannot bypass the +package encapsulation Node's resolvers enforce: a package reachable only +through a hoisted store or a parent directory stays refused, even though CJS +resolution can see it there, and a traversal specifier keeps the hard failure +it has today. And because the fallback runs only inside a catch that was a +hard failure before, no currently-succeeding load changes behaviour. **The split.** When the fallback cannot help either, the failure kind is decided by whether any install action could: a package absent from the host's @@ -40,3 +45,8 @@ and an operator is no longer sent to re-run `pnpm install` against a correct install. The new error still carries `code: 'MODULE_NOT_FOUND'`, so every existing caller's missing-vs-crashed classification is unchanged, and an evaluation crash still propagates untouched with no kind. + +Consumers that branch on `HostImportFailureKind` should add an arm for +`declared-no-loadable-entry`: a two-way branch written against the old +two-member union will fall into its else leg for the new kind, whose wording +("declare it") is wrong for a package that is declared and installed. diff --git a/packages/types/src/node.test.ts b/packages/types/src/node.test.ts index a264832283..9f07294b13 100644 --- a/packages/types/src/node.test.ts +++ b/packages/types/src/node.test.ts @@ -1119,4 +1119,127 @@ describe('the declared leg loads an ESM-only package via a hostRoot node_modules expect((err as Error).message).toMatch(/esm-only fixture exploded on import/); expect(hostImportFailureKind(err)).toBeUndefined(); }); + + /** + * TIGHTNESS, third axis (#14271 contract review): specifier VALIDATION. + * + * Node's resolvers — CJS and ESM alike — refuse an exports subpath whose + * pattern-matched span contains `.` / `..` / `node_modules` / empty + * segments (`ERR_INVALID_MODULE_SPECIFIER`; an import-only pattern refuses + * as `ERR_PACKAGE_PATH_NOT_EXPORTED` before ever validating the span). On + * the resolve-SUCCEEDED path (#13330) the specifier has therefore already + * been validated by the real resolver; inside the fallback's catch it has + * NOT — so without its own mirror of that refusal, a pattern key would + * substitute a traversal span into its target and reach a NON-EXPORTED file + * inside the package. Both cases below assert the real resolver's refusal + * first, so the pin proves its precondition rather than assuming it. + */ + it('TIGHTNESS: a traversal span in an ESM-only pattern subpath is refused, not resolved', async () => { + const TRAVERSAL = '@fixture/esm-sub-traversal'; + const root = app('traversal-esm', { [TRAVERSAL]: '1' }); + writeShapedPackage( + root, + TRAVERSAL, + { exports: { '.': { import: './dist/index.js' }, './deep/*': { import: './dist/deep/*.js' } } }, + { + 'dist/index.js': "export const WHERE = 'root';\n", + 'dist/deep/leaf.js': "export const WHERE = 'leaf';\n", + // Deliberately NOT exported — the file the traversal would reach. + 'secret/hidden.js': 'export const SECRET = true;\n', + }, + ); + const speller = `${TRAVERSAL}/deep/../../secret/hidden`; + let code: string | undefined; + try { + createHostRequire(root).resolve(speller); + } catch (e) { + code = (e as { code?: string }).code; + } + // The real CJS resolver refuses (import-only pattern: the condition is + // refused before the span is even validated). + expect(code).toBe('ERR_PACKAGE_PATH_NOT_EXPORTED'); + // And so does the fallback — never loading node_modules/…/secret/hidden.js. + const err = await createHostImporter(root)(speller).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + + // The pattern key itself still answers a VALID subpath — the segment + // refusal must not over-refuse legitimate pattern loads. + expect((await createHostImporter(root)(`${TRAVERSAL}/deep/leaf`)).WHERE).toBe('leaf'); + }); + + it('TIGHTNESS: a traversal span in a DUAL pattern subpath stays the hard failure it is on main', async () => { + // The widening the review measured: for a dual-published package the CJS + // resolve throws ERR_INVALID_MODULE_SPECIFIER — a hard failure today — so + // the throw enters the fallback's catch, and an unvalidated pattern match + // would load the package's non-exported internal file. Strictly-additive + // means this specifier must keep FAILING, with the same kind as main. + const TRAVERSAL = '@fixture/dual-sub-traversal'; + const root = app('traversal-dual', { [TRAVERSAL]: '1' }); + writeShapedPackage( + root, + TRAVERSAL, + { + exports: { + '.': { import: './dist/index.js', require: './dist/index.cjs' }, + './deep/*': { import: './dist/deep/*.js', require: './dist/deep/*.cjs' }, + }, + }, + { + 'dist/index.js': "export const WHERE = 'root';\n", + 'dist/index.cjs': "exports.WHERE = 'root-cjs';\n", + 'dist/deep/leaf.js': "export const WHERE = 'leaf';\n", + 'dist/deep/leaf.cjs': "exports.WHERE = 'leaf-cjs';\n", + 'secret/hidden.js': 'export const SECRET = true;\n', + }, + ); + const speller = `${TRAVERSAL}/deep/../../secret/hidden`; + let code: string | undefined; + try { + createHostRequire(root).resolve(speller); + } catch (e) { + code = (e as { code?: string }).code; + } + // The real CJS resolver validates the matched span and refuses it. + expect(code).toBe('ERR_INVALID_MODULE_SPECIFIER'); + const err = await createHostImporter(root)(speller).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + }); + + it('the fallback trusts only a directory whose manifest NAMES the declared package', async () => { + // Pin of the manifest-name check (previously unpinned): a directory at the + // declared path whose package.json carries a different name is not the + // declared package's install, and must not be rescued from. + const WRONG_NAME = '@fixture/wrong-name'; + const root = app('wrong-name', { [WRONG_NAME]: '1' }); + writeShapedPackage( + root, + WRONG_NAME, + { name: 'some-other-package', exports: ESM_ONLY_EXPORTS }, + { 'dist/index.js': "export const BUILD = 'imposter';\n" }, + ); + const err = await createHostImporter(root)(WRONG_NAME).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + }); + + it('the fallback refuses an exports target that ESCAPES its package', async () => { + // Pin of the containment check (previously unpinned): a `./`-prefixed + // target that resolves above the package root must not be loaded, exactly + // as Node refuses an escaping exports target on its own paths. + const ESCAPER = '@fixture/escaping-target'; + const root = app('escaper', { [ESCAPER]: '1' }); + writeShapedPackage(root, ESCAPER, { exports: { '.': { import: './../escaped-entry.js' } } }, {}); + // The file the escape WOULD reach, one level above the package dir. + writeFileSync( + join(root, 'node_modules', '@fixture', 'escaped-entry.js'), + 'export const ESCAPED = true;\n', + 'utf8', + ); + const err = await createHostImporter(root)(ESCAPER).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + expect((err as Error).message).toMatch(/INSTALL problem/); + }); }); diff --git a/packages/types/src/node.ts b/packages/types/src/node.ts index 7e45822d88..289513b082 100644 --- a/packages/types/src/node.ts +++ b/packages/types/src/node.ts @@ -682,7 +682,12 @@ function esmEntryForDeclared( * - no walk above `hostRoot` (CJS resolution climbs every parent's * `node_modules`; a package that exists only up there is someone else's); * - no bare `require`/`import` of the specifier (a second resolver would - * re-import every looseness one call at a time). + * re-import every looseness one call at a time); + * - Node's invalid-segment refusal, mirrored BEFORE exports resolution + * ({@link hasInvalidExportsSubpathSegments}): a subpath carrying `''`, + * `.`, `..` or `node_modules` segments is refused exactly as both of + * Node's resolvers refuse it — the one validation the specifier has NOT + * already passed by the time it reaches this catch (#14271 review). * * `import.meta.resolve` with a parent URL is NOT the mechanism, on the same * measurement the #10943 note below records: without @@ -715,7 +720,47 @@ type DeclaredCjsResolveFallback = /** Present, and its manifest names a runtime target — the FILES are the problem. */ | { outcome: 'install-broken' } /** Present, and its manifest names nothing loadable — the PACKAGE is the problem. */ - | { outcome: 'no-loadable-entry'; packageDir: string }; + | { outcome: 'no-loadable-entry'; packageDir: string } + /** + * The SPECIFIER is the problem: its subpath carries segments Node's own + * resolvers refuse (see {@link hasInvalidExportsSubpathSegments}). Never + * rescued and never re-worded — it keeps exactly the hard failure and the + * `declared-unresolvable` kind these specifiers get on the CJS path today. + */ + | { outcome: 'invalid-specifier' }; + +/** + * Mirror of Node's `PACKAGE_TARGET_RESOLVE` invalid-segment refusal, applied + * to the requested subpath BEFORE any exports resolution in the fallback + * (#14271 contract review). + * + * Both of Node's resolvers refuse an exports subpath whose segments include + * `''`, `.`, `..` or `node_modules` (case-insensitive) — + * `ERR_INVALID_MODULE_SPECIFIER`, or `ERR_PACKAGE_PATH_NOT_EXPORTED` when an + * import-only condition map refuses first. On the resolve-SUCCEEDED path + * (#13330) the specifier has therefore already been validated by the real + * resolver before the exports walk here ever sees it. Inside the fallback's + * catch it has NOT: without this mirror, a pattern key (`./deep/*`) would + * substitute a traversal span (`../../secret/hidden`) into its target and + * resolve a NON-EXPORTED file inside the package — the byte-containment check + * on the resolved entry permits any `..` traversal that lands back inside the + * package root, by design (it guards escape, not encapsulation). Measured on + * Node v22.22.2: `require.resolve` of such a specifier throws on both an + * import-only and a dual-published pattern map, so refusing here keeps the + * fallback strictly tighter than the CJS resolution it backs up on the + * VALIDATION axis, exactly as it is on the location axes. + */ +function hasInvalidExportsSubpathSegments(subpath: string): boolean { + if (subpath === '.') return false; + // `exportsSubpathOf` yields `./…`; validate every segment after that prefix. + return subpath + .slice(2) + .split(/[/\\]/) + .some((raw) => { + const segment = raw.toLowerCase(); + return segment === '' || segment === '.' || segment === '..' || segment === 'node_modules'; + }); +} /** * The one directory the fallback finder consults, verified to hold the @@ -766,6 +811,10 @@ function declaredCjsResolveFallback( if (exportsField === undefined || exportsField === null) return { outcome: 'install-broken' }; const subpath = exportsSubpathOf(specifier, packageName); + // Refused BEFORE exports resolution — the specifier reaches this walk + // unvalidated by any real resolver, unlike the #13330 path (see + // hasInvalidExportsSubpathSegments). + if (hasInvalidExportsSubpathSegments(subpath)) return { outcome: 'invalid-specifier' }; const importTarget = resolveExportsSubpath(exportsField, subpath, ESM_IMPORT_CONDITIONS); if (typeof importTarget === 'string' && importTarget.indexOf('./') === 0) {