Skip to content

fix(types): recognise an aliased package root by the name its declaration promises - #15563

Merged
os-litant merged 1 commit into
mainfrom
claude/issue-15044-package-root-alias-declaration-key
Sep 4, 2026
Merged

fix(types): recognise an aliased package root by the name its declaration promises#15563
os-litant merged 1 commit into
mainfrom
claude/issue-15044-package-root-alias-declaration-key

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #15044

createHostImporter's declared leg re-decides the condition for a package the CommonJS resolver has already located (the #13330 contract): it asks that package which entry an import() gets, so the caller's ESM chain and this load share one instance. That re-decision recognised the package root by walking up from the resolved entry until it found a package.json whose name equals the declaration key.

An aliased install — {"dependencies": {"foo": "npm:bar@1"}} — puts a manifest named bar under the key foo. The walk therefore never matched, esmEntryForDeclared returned undefined, and the ?? resolved at the call site handed back the entry CommonJS resolution had answered: the require condition. For an aliased dual-published package the process was left holding two live copies of one package — the CommonJS build behind the host importer, the import build in the caller's own chain — which is exactly the split #13330 exists to remove.

The population measurement, first

Triage fenced this card: if the affected population is not narrow — ordinary installs reach this path too, or an in-tree package is dual-published and aliased — stop and report. Measured before the fix, on origin/main. The fence holds.

Measurement Result
Alias declarations (npm:, aliased workspace:NAME@RANGE) across 81 workspace manifests / 875 dependency declarations 0 — 400 plain-range, 475 workspace: range
Alias specifiers in pnpm-lock.yaml 0 (6 npm: hits are all engines: maps and one dependency literally named npm; positive control: 855 version: hits)
In-tree dual-published packages (exports naming both an import and a require condition) 66
In-tree dual-published packages that are aliased anywhere 0 — the cross-set is empty
Installed declarations whose node_modules/KEY/package.json name equals the key — i.e. the walk matches, build-independent 867 of 867; 8 not installed at that root
Declarations resolvable from their own root today, run through a verbatim replica of packageRootOf 315 of 315 matched, 0 mismatches

Positive control for the zero: the same sweep pointed at a synthetic host declaring {"foo": "npm:bar@1"} reports 1 mismatch of 2 declarations, flagged dual-published. The instrument can report a mismatch; the repo has none.

⇒ No ordinary, non-aliased install reaches this path. The p3 grading and the "restores a contract, does not widen a surface" call both stand.

The change

esmEntryForDeclared now takes the whole HostDeclaration, exactly as the #14041 fallback leg has since #14278, and recognises the package root by declaredManifestName(declaration) — the name the host's own declaration promises. That helper moved above both legs rather than staying inside the fallback section, because both now read it.

Two names, two questions. The package root is recognised by the name the declaration promises; the exports subpath is still cut from the declaration key, because the key is what the specifier is spelled with — aliased/plugin addresses ./plugin of whatever aliased aliases. They coincide for every ordinary dependency, which is why one name served both until an alias pulled them apart. A regression test pins it.

Only the expectation moves, never the strictness. An alias naming one package still does not license a directory holding another; a non-aliased declaration is still verified against its key; link: / file: name a location rather than a package, so no name can be parsed out of them and they keep today's behaviour — pinned as a declared residue rather than left silent.

Nothing is exported. packageRootOf is module-private, called at one site, and named by neither subpath export of @objectstack/types, so the signature change is unreachable by any consumer.

Before / after, shown rather than described

Ablation on the committed tree at eb613ef6366: packageRootOf's argument reverted to the declaration key, mutation confirmed on disk by counting both the removed text (1 to 0) and an injected marker (0 to 1), plus a worktree blob-hash change.

Predicted direction, stated before the run: red — exactly the 5 aliased cases, with all 7 non-aliased / tightness / boundary controls staying green. Observed: Tests 5 failed | 65 passed (70), and the 5 are precisely the predicted ones.

× THE CARD: an aliased dual publish loads the `import` build, not the `require` one
    AssertionError: expected 'cjs' to be 'esm'
× a driver's load-time registration lands in the instance an ESM caller reads
    AssertionError: expected [] to deeply equal [ 'probe' ]
× and no longer lands in the CommonJS instance nothing reads
× the exports SUBPATH is still cut from the KEY, not from the aliased name
    AssertionError: expected 'root-cjs' to be 'root-esm'
× a `workspace:` ALIAS is recognised too; a plain `workspace:` range is a RANGE

Restore proved by blob-hash equality with the HEAD blob (97d373138e422e5e011596a33655eeb7314dd619), an empty git diff HEAD, an empty git status --porcelain, and an absent-marker count of 0.

Which resolution path the suite is on: source. src/node.test.ts imports ./node.js relatively and the package's vitest config declares no alias — and the ablation proves it rather than asserting it: packages/types/dist/node.js and dist/node.mjs existed at the time of the run (the whole workspace closure was built for the ratchet gates) and were never touched, yet a source-only mutation reddened the suite. A dist-resolving harness would have stayed green.

The controls that matter more than the fix

The risk here is not the aliased case being corrected — it is a regression in the ordinary case that already works. Four controls, all in the same host app as the aliased fixtures, all green before and after:

  • an ordinary plain-range dual publish still loads its import build;
  • an ordinary package publishing only a require condition still loads;
  • a plain-range key whose directory holds a different package is still not recognised;
  • a link: target whose manifest names something else keeps today's require-condition load.

Plus the reader control the #13330 suite established: an empty registry is proved to be a reading, not an instrument that can only ever answer empty.

No ADR-0112 envelope is asserted because none is produced: this path never throws, before or after — the change moves which entry a working load returns, and can turn no working load into a failure.

Verification

All run on eb613ef6366, the head of this branch; exit codes captured before any pipe.

  • pnpm --filter @objectstack/types test19 files, 573 tests passed (src/node.test.ts alone: 70 passed, 12 of them new)
  • pnpm --filter @objectstack/types typecheck — exit 0; tsc --noEmit --listFiles confirms both src/node.ts and src/node.test.ts are in the program, so the green covers the new tests
  • Gate union: all 48 families re-derived from the real changed paths by scripts/pm/dispatch-gates.mjs at this commit (36 by path + 7 by change kind + 7 declared whole-tree, 2 reached twice). 46 green on the first pass; check:dual-build-cjs-loads and check:type-check-debt answered PREREQUISITE NOT MET (exit 3, "this is NOT a pass") until the workspace closure was built, then both green — check:dual-build-cjs-loads: 103 published require entry points across 66 packages load; check:type-check-debt --re-measure: 13 ledger entries re-measured, 143 raw errors, none above its recorded number
  • pnpm lint (repo-wide eslint . --no-inline-config) — exit 0, no narrowing claimed
  • Control-byte self-scan of every touched file — clean, with a live positive control

Changeset

patch, for @objectstack/types. The Check Changeset rule reserves minor for a purely additive widening of a published package's public surface (a new exported symbol on an index, a new accepted key or value). This exports no new symbol, accepts no new key and no new value; it changes which entry a working import() returns for a population measured at zero in this workspace, restoring the condition contract the declared leg already promises. That is a bug fix in a released package, which takes patch. Not breaking — nothing an author can write is removed or renamed — so no ADR-0087 disposition marker is owed.


🤖 Generated with Claude Code

Generated by Claude Code


Generated by Claude Code

…tion promises

`createHostImporter`'s #13330 condition re-decision recognised the package root
by walking up from the CJS resolver's answer until it found a manifest named
after the DECLARATION KEY. An aliased install — `{"foo": "npm:bar@1"}` — puts a
manifest named `bar` there, so the walk never matched, `esmEntryForDeclared`
returned `undefined`, and `?? resolved` handed back the entry CommonJS
resolution had answered: the `require` condition. For an aliased dual publish
that is the two-instances-of-one-package split #13330 exists to remove.

The expectation now comes from `declaredManifestName` — the reading #14278 gave
the ESM-only fallback finder — so both legs expect one name from one place. The
helper moves above both rather than staying inside the fallback section. Only
the EXPECTATION moves, never the strictness: an alias naming one package still
does not license a directory holding another, and a non-aliased declaration is
still verified against its key. The exports SUBPATH stays cut from the KEY,
which is what the specifier is spelled with.

`packageRootOf` is module-private, called at one site, and named by neither
subpath export of `@objectstack/types`, so the signature change is unreachable
by any consumer.

Part of #15044

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 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 — 2 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 cf6b67164e37b92cf1fb6b172aa8f44e1fdce666packageMentionDocs.

Which tree this was computed on

This run read content/docs from 05362b555824f655d7780496e06bacaec514f081 — the merge of head eb613ef6366ee8d24e90d958ed600791120af0c5 into base cf6b67164e37b92cf1fb6b172aa8f44e1fdce666, 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 05362b555824f655d7780496e06bacaec514f081 && git checkout 05362b555824f655d7780496e06bacaec514f081
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cf6b67164e37b92cf1fb6b172aa8f44e1fdce666 eb613ef6366ee8d24e90d958ed600791120af0c5 && git checkout -B drift-repro cf6b67164e37b92cf1fb6b172aa8f44e1fdce666 && git merge --no-ff eb613ef6366ee8d24e90d958ed600791120af0c5

node scripts/docs-audit/affected-docs.mjs --json cf6b67164e37b92cf1fb6b172aa8f44e1fdce666

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

packageRootOf matches the declaration KEY, so an aliased dual-published package silently loads its require build on the succeeding (#13330) path

2 participants