fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package
Sep 1, 2026
Merged

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind#14271
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14041

Defect

createHostImporter's declared leg finds a package with hostRequire.resolve(pkg) — a CommonJS resolution. A host-app package publishing only an import condition (the card's shape: type: module, exports mapping . to an import target only) makes that resolve throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw as declared-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.resolve throws — 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's node_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 existing import-condition target for the requested subpath, it is imported.

The triage ruling this implements, verbatim from the card:

a node_modules walk from hostRoot, which is strictly tighter than CJS resolution (it does not honour NODE_PATH, so it cannot reopen the #4719 hole)

Strictly tighter, concretely, on three axes each carrying its own pins: no NODE_PATH and no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails with ERR_PACKAGE_PATH_NOT_EXPORTED — found, condition-refused — before asserting the importer still refuses); no bare require or second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, or node_modules is 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_EXPORTED and ERR_INVALID_MODULE_SPECIFIER respectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too. import.meta.resolve was 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?

  • Not present in the host's 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.
  • Present, and its manifest names NO runtime entry for the subpath under either the require or the import conditions (types-only or browser-only publish, unexported subpath): the new HostImportFailureKind value declared-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.resolve had 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 is hasInvalidExportsSubpathSegments, 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 an npm: 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-review

This 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 at 3728813b79, this branch's final commit)

  • Failure proved first: the new fixture suite ran against unmodified src — the card's exact shape failed as MODULE_NOT_FOUND with 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.
  • Fix: pnpm --filter @objectstack/types testTest 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 --noEmit green with --listFiles showing both edited files inside the program (the package excludes no tests).
  • Cross-package type evidence: stale dist contained 0 occurrences of the new kind; after pnpm --filter @objectstack/types build, it appears in dist/node.d.ts and dist/node.d.mts (and both runtime builds).
  • Ablations (fix committed first in both rounds; mutation confirmed on disk by anchored grep marker counts and 1-insertion diff stats; the suite resolves the subject via the same-package relative src import, so no dist is in the measured path): round 1 — fallback short-circuited to not-installed: exactly the 5 rescue/split cases red, 36 green; patch round — segment guard forced false: exactly the 2 traversal pins red, 43 green. Both restores proven by clean git diff HEAD and git hash-object equal to the HEAD blob.
  • Gate union: node scripts/pm/dispatch-gates.mjs (self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against --commands with comm (empty both directions). 32 ran green at 3728813b79, including pnpm check:dual-build-cjs-loads after building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — and pnpm 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") and check-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-bytes green.

Changeset

.changeset/host-importer-esm-only-walk.md, @objectstack/types minor (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

…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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 16 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/cli.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/self-hosting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/tenancy-modes.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/troubleshooting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/examples.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/your-first-project.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/development.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/plugin-spec.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/objectql/schema.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 — the merge of head 3728813b79ea2819c65af3feaa6d42f164b0a4e7 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb8e3cc5022c4b1d0007962220444f4f947036d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package
Sep 1, 2026
Merged

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind#14271
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14041

Defect

createHostImporter's declared leg finds a package with hostRequire.resolve(pkg) — a CommonJS resolution. A host-app package publishing only an import condition (the card's shape: type: module, exports mapping . to an import target only) makes that resolve throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw as declared-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.resolve throws — 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's node_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 existing import-condition target for the requested subpath, it is imported.

The triage ruling this implements, verbatim from the card:

a node_modules walk from hostRoot, which is strictly tighter than CJS resolution (it does not honour NODE_PATH, so it cannot reopen the #4719 hole)

Strictly tighter, concretely, on three axes each carrying its own pins: no NODE_PATH and no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails with ERR_PACKAGE_PATH_NOT_EXPORTED — found, condition-refused — before asserting the importer still refuses); no bare require or second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, or node_modules is 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_EXPORTED and ERR_INVALID_MODULE_SPECIFIER respectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too. import.meta.resolve was 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?

  • Not present in the host's 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.
  • Present, and its manifest names NO runtime entry for the subpath under either the require or the import conditions (types-only or browser-only publish, unexported subpath): the new HostImportFailureKind value declared-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.resolve had 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 is hasInvalidExportsSubpathSegments, 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 an npm: 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-review

This 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 at 3728813b79, this branch's final commit)

  • Failure proved first: the new fixture suite ran against unmodified src — the card's exact shape failed as MODULE_NOT_FOUND with 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.
  • Fix: pnpm --filter @objectstack/types testTest 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 --noEmit green with --listFiles showing both edited files inside the program (the package excludes no tests).
  • Cross-package type evidence: stale dist contained 0 occurrences of the new kind; after pnpm --filter @objectstack/types build, it appears in dist/node.d.ts and dist/node.d.mts (and both runtime builds).
  • Ablations (fix committed first in both rounds; mutation confirmed on disk by anchored grep marker counts and 1-insertion diff stats; the suite resolves the subject via the same-package relative src import, so no dist is in the measured path): round 1 — fallback short-circuited to not-installed: exactly the 5 rescue/split cases red, 36 green; patch round — segment guard forced false: exactly the 2 traversal pins red, 43 green. Both restores proven by clean git diff HEAD and git hash-object equal to the HEAD blob.
  • Gate union: node scripts/pm/dispatch-gates.mjs (self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against --commands with comm (empty both directions). 32 ran green at 3728813b79, including pnpm check:dual-build-cjs-loads after building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — and pnpm 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") and check-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-bytes green.

Changeset

.changeset/host-importer-esm-only-walk.md, @objectstack/types minor (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

…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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 16 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/cli.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/self-hosting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/tenancy-modes.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/troubleshooting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/examples.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/your-first-project.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/development.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/plugin-spec.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/objectql/schema.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 — the merge of head 3728813b79ea2819c65af3feaa6d42f164b0a4e7 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb8e3cc5022c4b1d0007962220444f4f947036d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package
Sep 1, 2026
Merged

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind#14271
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14041

Defect

createHostImporter's declared leg finds a package with hostRequire.resolve(pkg) — a CommonJS resolution. A host-app package publishing only an import condition (the card's shape: type: module, exports mapping . to an import target only) makes that resolve throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw as declared-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.resolve throws — 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's node_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 existing import-condition target for the requested subpath, it is imported.

The triage ruling this implements, verbatim from the card:

a node_modules walk from hostRoot, which is strictly tighter than CJS resolution (it does not honour NODE_PATH, so it cannot reopen the #4719 hole)

Strictly tighter, concretely, on three axes each carrying its own pins: no NODE_PATH and no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails with ERR_PACKAGE_PATH_NOT_EXPORTED — found, condition-refused — before asserting the importer still refuses); no bare require or second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, or node_modules is 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_EXPORTED and ERR_INVALID_MODULE_SPECIFIER respectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too. import.meta.resolve was 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?

  • Not present in the host's 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.
  • Present, and its manifest names NO runtime entry for the subpath under either the require or the import conditions (types-only or browser-only publish, unexported subpath): the new HostImportFailureKind value declared-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.resolve had 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 is hasInvalidExportsSubpathSegments, 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 an npm: 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-review

This 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 at 3728813b79, this branch's final commit)

  • Failure proved first: the new fixture suite ran against unmodified src — the card's exact shape failed as MODULE_NOT_FOUND with 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.
  • Fix: pnpm --filter @objectstack/types testTest 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 --noEmit green with --listFiles showing both edited files inside the program (the package excludes no tests).
  • Cross-package type evidence: stale dist contained 0 occurrences of the new kind; after pnpm --filter @objectstack/types build, it appears in dist/node.d.ts and dist/node.d.mts (and both runtime builds).
  • Ablations (fix committed first in both rounds; mutation confirmed on disk by anchored grep marker counts and 1-insertion diff stats; the suite resolves the subject via the same-package relative src import, so no dist is in the measured path): round 1 — fallback short-circuited to not-installed: exactly the 5 rescue/split cases red, 36 green; patch round — segment guard forced false: exactly the 2 traversal pins red, 43 green. Both restores proven by clean git diff HEAD and git hash-object equal to the HEAD blob.
  • Gate union: node scripts/pm/dispatch-gates.mjs (self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against --commands with comm (empty both directions). 32 ran green at 3728813b79, including pnpm check:dual-build-cjs-loads after building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — and pnpm 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") and check-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-bytes green.

Changeset

.changeset/host-importer-esm-only-walk.md, @objectstack/types minor (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

…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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 16 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/cli.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/self-hosting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/tenancy-modes.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/troubleshooting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/examples.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/your-first-project.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/development.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/plugin-spec.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/objectql/schema.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 — the merge of head 3728813b79ea2819c65af3feaa6d42f164b0a4e7 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb8e3cc5022c4b1d0007962220444f4f947036d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package
Sep 1, 2026
Merged

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind#14271
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14041

Defect

createHostImporter's declared leg finds a package with hostRequire.resolve(pkg) — a CommonJS resolution. A host-app package publishing only an import condition (the card's shape: type: module, exports mapping . to an import target only) makes that resolve throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw as declared-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.resolve throws — 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's node_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 existing import-condition target for the requested subpath, it is imported.

The triage ruling this implements, verbatim from the card:

a node_modules walk from hostRoot, which is strictly tighter than CJS resolution (it does not honour NODE_PATH, so it cannot reopen the #4719 hole)

Strictly tighter, concretely, on three axes each carrying its own pins: no NODE_PATH and no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails with ERR_PACKAGE_PATH_NOT_EXPORTED — found, condition-refused — before asserting the importer still refuses); no bare require or second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, or node_modules is 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_EXPORTED and ERR_INVALID_MODULE_SPECIFIER respectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too. import.meta.resolve was 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?

  • Not present in the host's 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.
  • Present, and its manifest names NO runtime entry for the subpath under either the require or the import conditions (types-only or browser-only publish, unexported subpath): the new HostImportFailureKind value declared-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.resolve had 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 is hasInvalidExportsSubpathSegments, 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 an npm: 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-review

This 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 at 3728813b79, this branch's final commit)

  • Failure proved first: the new fixture suite ran against unmodified src — the card's exact shape failed as MODULE_NOT_FOUND with 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.
  • Fix: pnpm --filter @objectstack/types testTest 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 --noEmit green with --listFiles showing both edited files inside the program (the package excludes no tests).
  • Cross-package type evidence: stale dist contained 0 occurrences of the new kind; after pnpm --filter @objectstack/types build, it appears in dist/node.d.ts and dist/node.d.mts (and both runtime builds).
  • Ablations (fix committed first in both rounds; mutation confirmed on disk by anchored grep marker counts and 1-insertion diff stats; the suite resolves the subject via the same-package relative src import, so no dist is in the measured path): round 1 — fallback short-circuited to not-installed: exactly the 5 rescue/split cases red, 36 green; patch round — segment guard forced false: exactly the 2 traversal pins red, 43 green. Both restores proven by clean git diff HEAD and git hash-object equal to the HEAD blob.
  • Gate union: node scripts/pm/dispatch-gates.mjs (self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against --commands with comm (empty both directions). 32 ran green at 3728813b79, including pnpm check:dual-build-cjs-loads after building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — and pnpm 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") and check-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-bytes green.

Changeset

.changeset/host-importer-esm-only-walk.md, @objectstack/types minor (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

…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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 16 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/cli.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/self-hosting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/tenancy-modes.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/troubleshooting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/examples.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/your-first-project.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/development.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/plugin-spec.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/objectql/schema.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 — the merge of head 3728813b79ea2819c65af3feaa6d42f164b0a4e7 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb8e3cc5022c4b1d0007962220444f4f947036d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package
Sep 1, 2026
Merged

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind#14271
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14041

Defect

createHostImporter's declared leg finds a package with hostRequire.resolve(pkg) — a CommonJS resolution. A host-app package publishing only an import condition (the card's shape: type: module, exports mapping . to an import target only) makes that resolve throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw as declared-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.resolve throws — 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's node_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 existing import-condition target for the requested subpath, it is imported.

The triage ruling this implements, verbatim from the card:

a node_modules walk from hostRoot, which is strictly tighter than CJS resolution (it does not honour NODE_PATH, so it cannot reopen the #4719 hole)

Strictly tighter, concretely, on three axes each carrying its own pins: no NODE_PATH and no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails with ERR_PACKAGE_PATH_NOT_EXPORTED — found, condition-refused — before asserting the importer still refuses); no bare require or second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, or node_modules is 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_EXPORTED and ERR_INVALID_MODULE_SPECIFIER respectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too. import.meta.resolve was 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?

  • Not present in the host's 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.
  • Present, and its manifest names NO runtime entry for the subpath under either the require or the import conditions (types-only or browser-only publish, unexported subpath): the new HostImportFailureKind value declared-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.resolve had 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 is hasInvalidExportsSubpathSegments, 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 an npm: 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-review

This 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 at 3728813b79, this branch's final commit)

  • Failure proved first: the new fixture suite ran against unmodified src — the card's exact shape failed as MODULE_NOT_FOUND with 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.
  • Fix: pnpm --filter @objectstack/types testTest 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 --noEmit green with --listFiles showing both edited files inside the program (the package excludes no tests).
  • Cross-package type evidence: stale dist contained 0 occurrences of the new kind; after pnpm --filter @objectstack/types build, it appears in dist/node.d.ts and dist/node.d.mts (and both runtime builds).
  • Ablations (fix committed first in both rounds; mutation confirmed on disk by anchored grep marker counts and 1-insertion diff stats; the suite resolves the subject via the same-package relative src import, so no dist is in the measured path): round 1 — fallback short-circuited to not-installed: exactly the 5 rescue/split cases red, 36 green; patch round — segment guard forced false: exactly the 2 traversal pins red, 43 green. Both restores proven by clean git diff HEAD and git hash-object equal to the HEAD blob.
  • Gate union: node scripts/pm/dispatch-gates.mjs (self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against --commands with comm (empty both directions). 32 ran green at 3728813b79, including pnpm check:dual-build-cjs-loads after building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — and pnpm 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") and check-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-bytes green.

Changeset

.changeset/host-importer-esm-only-walk.md, @objectstack/types minor (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

…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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 16 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/cli.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/self-hosting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/tenancy-modes.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/troubleshooting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/examples.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/your-first-project.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/development.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/plugin-spec.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/objectql/schema.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 — the merge of head 3728813b79ea2819c65af3feaa6d42f164b0a4e7 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb8e3cc5022c4b1d0007962220444f4f947036d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package
Sep 1, 2026
Merged

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind#14271
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14041

Defect

createHostImporter's declared leg finds a package with hostRequire.resolve(pkg) — a CommonJS resolution. A host-app package publishing only an import condition (the card's shape: type: module, exports mapping . to an import target only) makes that resolve throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw as declared-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.resolve throws — 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's node_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 existing import-condition target for the requested subpath, it is imported.

The triage ruling this implements, verbatim from the card:

a node_modules walk from hostRoot, which is strictly tighter than CJS resolution (it does not honour NODE_PATH, so it cannot reopen the #4719 hole)

Strictly tighter, concretely, on three axes each carrying its own pins: no NODE_PATH and no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails with ERR_PACKAGE_PATH_NOT_EXPORTED — found, condition-refused — before asserting the importer still refuses); no bare require or second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, or node_modules is 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_EXPORTED and ERR_INVALID_MODULE_SPECIFIER respectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too. import.meta.resolve was 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?

  • Not present in the host's 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.
  • Present, and its manifest names NO runtime entry for the subpath under either the require or the import conditions (types-only or browser-only publish, unexported subpath): the new HostImportFailureKind value declared-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.resolve had 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 is hasInvalidExportsSubpathSegments, 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 an npm: 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-review

This 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 at 3728813b79, this branch's final commit)

  • Failure proved first: the new fixture suite ran against unmodified src — the card's exact shape failed as MODULE_NOT_FOUND with 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.
  • Fix: pnpm --filter @objectstack/types testTest 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 --noEmit green with --listFiles showing both edited files inside the program (the package excludes no tests).
  • Cross-package type evidence: stale dist contained 0 occurrences of the new kind; after pnpm --filter @objectstack/types build, it appears in dist/node.d.ts and dist/node.d.mts (and both runtime builds).
  • Ablations (fix committed first in both rounds; mutation confirmed on disk by anchored grep marker counts and 1-insertion diff stats; the suite resolves the subject via the same-package relative src import, so no dist is in the measured path): round 1 — fallback short-circuited to not-installed: exactly the 5 rescue/split cases red, 36 green; patch round — segment guard forced false: exactly the 2 traversal pins red, 43 green. Both restores proven by clean git diff HEAD and git hash-object equal to the HEAD blob.
  • Gate union: node scripts/pm/dispatch-gates.mjs (self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against --commands with comm (empty both directions). 32 ran green at 3728813b79, including pnpm check:dual-build-cjs-loads after building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — and pnpm 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") and check-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-bytes green.

Changeset

.changeset/host-importer-esm-only-walk.md, @objectstack/types minor (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

…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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 16 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/cli.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/self-hosting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/tenancy-modes.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/troubleshooting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/examples.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/your-first-project.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/development.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/plugin-spec.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/objectql/schema.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 — the merge of head 3728813b79ea2819c65af3feaa6d42f164b0a4e7 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb8e3cc5022c4b1d0007962220444f4f947036d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package
Sep 1, 2026
Merged

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind#14271
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14041

Defect

createHostImporter's declared leg finds a package with hostRequire.resolve(pkg) — a CommonJS resolution. A host-app package publishing only an import condition (the card's shape: type: module, exports mapping . to an import target only) makes that resolve throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw as declared-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.resolve throws — 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's node_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 existing import-condition target for the requested subpath, it is imported.

The triage ruling this implements, verbatim from the card:

a node_modules walk from hostRoot, which is strictly tighter than CJS resolution (it does not honour NODE_PATH, so it cannot reopen the #4719 hole)

Strictly tighter, concretely, on three axes each carrying its own pins: no NODE_PATH and no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails with ERR_PACKAGE_PATH_NOT_EXPORTED — found, condition-refused — before asserting the importer still refuses); no bare require or second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, or node_modules is 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_EXPORTED and ERR_INVALID_MODULE_SPECIFIER respectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too. import.meta.resolve was 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?

  • Not present in the host's 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.
  • Present, and its manifest names NO runtime entry for the subpath under either the require or the import conditions (types-only or browser-only publish, unexported subpath): the new HostImportFailureKind value declared-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.resolve had 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 is hasInvalidExportsSubpathSegments, 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 an npm: 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-review

This 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 at 3728813b79, this branch's final commit)

  • Failure proved first: the new fixture suite ran against unmodified src — the card's exact shape failed as MODULE_NOT_FOUND with 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.
  • Fix: pnpm --filter @objectstack/types testTest 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 --noEmit green with --listFiles showing both edited files inside the program (the package excludes no tests).
  • Cross-package type evidence: stale dist contained 0 occurrences of the new kind; after pnpm --filter @objectstack/types build, it appears in dist/node.d.ts and dist/node.d.mts (and both runtime builds).
  • Ablations (fix committed first in both rounds; mutation confirmed on disk by anchored grep marker counts and 1-insertion diff stats; the suite resolves the subject via the same-package relative src import, so no dist is in the measured path): round 1 — fallback short-circuited to not-installed: exactly the 5 rescue/split cases red, 36 green; patch round — segment guard forced false: exactly the 2 traversal pins red, 43 green. Both restores proven by clean git diff HEAD and git hash-object equal to the HEAD blob.
  • Gate union: node scripts/pm/dispatch-gates.mjs (self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against --commands with comm (empty both directions). 32 ran green at 3728813b79, including pnpm check:dual-build-cjs-loads after building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — and pnpm 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") and check-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-bytes green.

Changeset

.changeset/host-importer-esm-only-walk.md, @objectstack/types minor (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

…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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 16 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/cli.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/self-hosting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/tenancy-modes.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/troubleshooting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/examples.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/your-first-project.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/development.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/plugin-spec.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/objectql/schema.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 — the merge of head 3728813b79ea2819c65af3feaa6d42f164b0a4e7 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb8e3cc5022c4b1d0007962220444f4f947036d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install

2 participants

@os-justin@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind - #14271

Merged
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package
Sep 1, 2026
Merged

fix(types): load a declared ESM-only host package through a strictly-tighter hostRoot node_modules walk, and split the failure kind#14271
os-justin merged 4 commits into
mainfrom
claude/issue-14041-esm-only-host-package

Conversation

@os-justin

@os-justinos-justin commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#14041

Defect

createHostImporter's declared leg finds a package with hostRequire.resolve(pkg) — a CommonJS resolution. A host-app package publishing only an import condition (the card's shape: type: module, exports mapping . to an import target only) makes that resolve throw ERR_PACKAGE_PATH_NOT_EXPORTED, and the leg classified every resolver throw as declared-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.resolve throws — 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's node_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 existing import-condition target for the requested subpath, it is imported.

The triage ruling this implements, verbatim from the card:

a node_modules walk from hostRoot, which is strictly tighter than CJS resolution (it does not honour NODE_PATH, so it cannot reopen the #4719 hole)

Strictly tighter, concretely, on three axes each carrying its own pins: no NODE_PATH and no walk above the host root (location — two pins on layouts CJS resolution demonstrably CAN see: each asserts the CJS resolve fails with ERR_PACKAGE_PATH_NOT_EXPORTED — found, condition-refused — before asserting the importer still refuses); no bare require or second resolver; and Node's invalid-segment refusal mirrored BEFORE exports resolution (validation — a subpath whose segments include empty, dot, dot-dot, or node_modules is 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_EXPORTED and ERR_INVALID_MODULE_SPECIFIER respectively). The previously-unpinned manifest-name and escape-containment checks now carry pins too. import.meta.resolve was 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?

  • Not present in the host's 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.
  • Present, and its manifest names NO runtime entry for the subpath under either the require or the import conditions (types-only or browser-only publish, unexported subpath): the new HostImportFailureKind value declared-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.resolve had 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 is hasInvalidExportsSubpathSegments, 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 an npm: 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-review

This 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 at 3728813b79, this branch's final commit)

  • Failure proved first: the new fixture suite ran against unmodified src — the card's exact shape failed as MODULE_NOT_FOUND with 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.
  • Fix: pnpm --filter @objectstack/types testTest 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 --noEmit green with --listFiles showing both edited files inside the program (the package excludes no tests).
  • Cross-package type evidence: stale dist contained 0 occurrences of the new kind; after pnpm --filter @objectstack/types build, it appears in dist/node.d.ts and dist/node.d.mts (and both runtime builds).
  • Ablations (fix committed first in both rounds; mutation confirmed on disk by anchored grep marker counts and 1-insertion diff stats; the suite resolves the subject via the same-package relative src import, so no dist is in the measured path): round 1 — fallback short-circuited to not-installed: exactly the 5 rescue/split cases red, 36 green; patch round — segment guard forced false: exactly the 2 traversal pins red, 43 green. Both restores proven by clean git diff HEAD and git hash-object equal to the HEAD blob.
  • Gate union: node scripts/pm/dispatch-gates.mjs (self-derived change set; re-derived at each head, unchanged) = 34 families, reconciled against --commands with comm (empty both directions). 32 ran green at 3728813b79, including pnpm check:dual-build-cjs-loads after building the workspace closure — its own verdict line: "provenance — entries/packages/cjsFiles/probes: this run 102/66/610/1 · floors 90/58/520/1" — and pnpm 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") and check-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-bytes green.

Changeset

.changeset/host-importer-esm-only-walk.md, @objectstack/types minor (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

…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
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/types, touching 16 documentable anchor(s).

12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/cli.mdx(via node_modules (literal, a string literal in hasInvalidExportsSubpathSegments; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/self-hosting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/tenancy-modes.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/deployment/troubleshooting.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/examples.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/getting-started/your-first-project.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/development.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/plugins/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/index.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/kernel/plugin-spec.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
  • content/docs/protocol/objectql/schema.mdx(via package.json (literal, a string literal in declaredCjsResolveFallback; a string literal in hostInstalledPackageDir))
What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 1 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6eb8e3cc5022c4b1d0007962220444f4f947036dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from a8bcf0e59b0c52ce336829c4f5f6d9c765db7529 — the merge of head 3728813b79ea2819c65af3feaa6d42f164b0a4e7 into base 6eb8e3cc5022c4b1d0007962220444f4f947036d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6eb8e3cc5022c4b1d0007962220444f4f947036d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createHostImporter's declared leg cannot load an ESM-only host package at all, and misreports it as a broken install

2 participants

@os-justin@claude