Uh oh!
There was an error while loading. Please reload this page.
fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271
Conversation
…n 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
…ter hostRoot node_modules walk (#14041) 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/<name> — 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
📓 Docs Drift CheckThis PR changes 1 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 && git checkout a8bcf0e59b0c52ce336829c4f5f6d9c765db7529
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eb8e3cc5022c4b1d0007962220444f4f947036d 3728813b79ea2819c65af3feaa6d42f164b0a4e7 && git checkout -B drift-repro 6eb8e3cc5022c4b1d0007962220444f4f947036d && git merge --no-ff 3728813b79ea2819c65af3feaa6d42f164b0a4e7
node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036d
|
…lution in the #14041 fallback (#14271 review) 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14041
Defect
createHostImporter's declared leg finds a package withhostRequire.resolve(pkg)— a CommonJS resolution. A host-app package publishing only animportcondition (the card's shape:type: module, exports mapping.to animporttarget only) makes that resolve throwERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw asdeclared-unresolvable: a hard failure worded as an INSTALL problem, about an install that is fine. Nothing the message prescribed could help. Latent for this workspace (every dual build here publishes both conditions), live for any customer or cloud-private ESM-only package.Mechanism
Finder. Only when
hostRequire.resolvethrows — a path that was a hard failure before, so no currently-succeeding load can change behaviour — the leg now consults exactly one directory: the package's own folder under the host app'snode_modules(name verified against the package's manifest, then realpath'd so its transitive imports resolve from its real location, as on the succeeding path). If that package's exports names an existingimport-condition target for the requested subpath, it is imported.The triage ruling this implements, verbatim from the card:
Strictly tighter, concretely, on three axes each carrying its own pins: no
NODE_PATHand no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails withERR_PACKAGE_PATH_NOT_EXPORTED— found, condition-refused — before asserting the importer still refuses); no barerequireor second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, ornode_modulesis refused exactly as both of Node's resolvers refuse it, so a pattern key can never substitute a traversal span into its target and reach a non-exported file; two pins, ESM-only and dual-published, each asserting the real resolver's refusal code first —ERR_PACKAGE_PATH_NOT_EXPORTEDandERR_INVALID_MODULE_SPECIFIERrespectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too.import.meta.resolvewas not used, on the file's own recorded measurement: without the experimental flag its parent argument is silently ignored, answering from the wrong base with full confidence.Failure-kind split. When the fallback cannot help either, the kind is decided by one criterion — can any install action ever help?
node_modules, or its manifest names a runtime target whose file is missing (dist never built, partial publish):declared-unresolvable, the existing INSTALL wording, byte-unchanged. An invalid-segment specifier also keeps exactly this kind — the hard failure it has on main today.requireor theimportconditions (types-only or browser-only publish, unexported subpath): the newHostImportFailureKindvaluedeclared-no-loadable-entry, with a message about the package's own published shape. The two kinds keep separate messages — the wrong verdict was not traded for a vague one.The new error still carries
code: MODULE_NOT_FOUND, so every caller's missing-vs-crashed classification (isModuleNotFoundError) is unchanged, and an evaluation crash still propagates untouched with no kind.Contract-review patch round (head
3728813b79)The at-tier review measured a real gap on the validation axis: the fallback received the specifier unvalidated by any resolver (on the #13330 path,
hostRequire.resolvehad already validated it), so a pattern key substituted a traversal span into its target and loaded a package's non-exported internal file — reproduced here red-first on both an ESM-only and a dual-published fixture before the guard, green after, and the guard was ablated (marker-confirmed on disk, restore hash-proven) to show exactly the two traversal pins catch its removal. The fix ishasInvalidExportsSubpathSegments, applied before exports resolution; refused specifiers keep main's exact failure kind. The rescue, the failure-kind split, the location pins, the semver grading and everything else in the diff are otherwise unchanged. Known survival, declared for a follow-up card: an aliased install (manifest key mapping to annpm:alias) fails the manifest-name check and keeps the INSTALL wording for an ESM-only aliased package — correct alias handling needs the declaration specifier parsed (npm:/link:/workspace:), its own card.Contract note —
needs:contract-reviewThis diff changes what the importer accepts: a declared, installed ESM-only package that hard-failed on main now loads, and one class of resolver throw carries a new kind value. Declared conservatively per the dispatch clause; the label is on this PR. The widened union is additive — no repo consumer switches exhaustively on the kind (measured by grep; the three remedy-formatter consumers branch two-way and keep working, see the finding below) — and the changeset now carries an explicit warning that kind-branching consumers should add an arm.
Verification (round 1 at
69e617764e; patch round re-run in full at3728813b79, this branch's final commit)MODULE_NOT_FOUNDwith the "This is an INSTALL problem" wording (5 red: the rescue and split cases; 6 green by design: CJS-resolution precondition, dual-build positive control, both location-tightness pins, both case-a pins). The patch round repeated the discipline: both traversal pins red against the unguarded fallback (the importer resolved the non-exported file), green after the guard.pnpm --filter @objectstack/types test—Test Files 16 passed (16),Tests 497 passed (497), including the 15 createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install #14041 cases.tsc --noEmitgreen with--listFilesshowing both edited files inside the program (the package excludes no tests).distcontained 0 occurrences of the new kind; afterpnpm --filter @objectstack/types build, it appears indist/node.d.tsanddist/node.d.mts(and both runtime builds).git diff HEADandgit hash-objectequal to the HEAD blob.node scripts/pm/dispatch-gates.mjs(self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against--commandswithcomm(empty both directions). 32 ran green at3728813b79, includingpnpm check:dual-build-cjs-loadsafter building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — andpnpm check:type-check-debt— "OK — 27 ledger entr(ies) re-measured ... none above its recorded number". 2 families are NOT MEASURED by their own refusal text, both CI-covered:check-test-completeness.mjs(needs a saved test-run log CI tees; exit 3 "not a red, nothing to fix") andcheck-half-states.mjs(needs repo-scoped egress this container refuses; "no reading at all").pnpm lint(whole repo,--no-inline-config) green at both heads.check:nul-bytesgreen.Changeset
.changeset/host-importer-esm-only-walk.md,@objectstack/typesminor (additive union member on a public type plus new load capability). Not breaking — no ADR-0087 disposition owed.Out of scope, filed
The three failure-kind remedy formatters (serve, verify harness, dogfood probe) branch two-way and will word the new kind with the declare-remedy — filed as #14270 (Blocked-by this card; #14270 remains open and is not addressed here).
Session: https://claude.ai/code/session_015YPiiDdw96RGS25WLctCQP
Generated by Claude Code