fix(types): a link:/file: install's refusal states the LIMIT, not a false install remedy - #17030
Conversation
…e install remedy `createHostImporter`'s ESM-only fallback finder refuses a `link:` / `file:` install whose linked manifest names something other than the declaration key — correctly, and deliberately, since a location specifier carries no package name to expect and accepting the directory anyway would trade a wrong REMEDY for a wrong LOAD. It reported that refusal with the `declared-unresolvable` INSTALL wording, every remedy of which is measurably false for this shape: the finder had just read the manifest at `node_modules/<key>`, so the package is on disk, was not pruned, and its `import` target exists. The refusal is unchanged — same kind, same `MODULE_NOT_FOUND`, same exit path, same accept set. Only the words change: the message now states the directory it consulted, the name found there, the name expected, why a location specifier leaves it only the key, and the remedy that works (make the two names agree, from either end — both pinned as loading). The second verification axis the card also proposes — comparing `realpath(node_modules/<key>)` against the declared location, which would make these installs LOAD — is deliberately not built here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 1 package(s): 13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 16aeaf481c922df718aea311ccb007adae2bbafb && git checkout 16aeaf481c922df718aea311ccb007adae2bbafb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d127f9babce9d483ac4c053194360968e1c7ba0a 8e79a6bb3ad66986f50983fa734922f2170c03da && git checkout -B drift-repro d127f9babce9d483ac4c053194360968e1c7ba0a && git merge --no-ff 8e79a6bb3ad66986f50983fa734922f2170c03da
node scripts/docs-audit/affected-docs.mjs --json d127f9babce9d483ac4c053194360968e1c7ba0a
|
Fixes #15045
Clause-②: no
The
link:/file:sub-case #14278 deliberately left standing was refusing correctly and explaining itself wrongly. This PR changes only what the refusal SAYS. The refusal, its kind, its code, its exit path and the finder's accept set are byte-for-byte what they were.⛔ Not built here: the card's other route — the second verification axis (
realpath(node_modules/KEY)compared against the declared location) — which would make these installs LOAD. That relaxes a published accept set and is a contract decision, not a diagnostic one. The card stays open for it.The failure, driven
A host app declaring
{"dependencies": {"linked-other": "link:../elsewhere"}},node_modules/linked-othera real symlink to a sibling directory whose manifest is named@fixture/some-other-name, ESM-only exports.hostRequire.resolvethrowsERR_PACKAGE_PATH_NOT_EXPORTED(measured), so the #14041 fallback decides.BEFORE — verbatim:
All three prescribed checks are measurably false for this shape. The finder had just read the manifest at
node_modules/linked-other, so: the package is on disk (not "never installed"), it was not pruned, and itsimporttarget exists (the dist IS built). The operator runspnpm install, nothing changes, and goes hunting for a build that is not broken.AFTER — same input, same fixture:
Every claim in the new message was measured, not reasoned. Both closing remedies were driven and both LOAD (pinned as tests): renaming the key to the linked manifest's name, and renaming the linked manifest to the key.
⛔ One remedy was deliberately removed during drafting rather than printed: "have the package publish a
requirecondition." It does make the load succeed — and that is the problem. A dual build resolves through CommonJS, so #13330's condition re-decision runs instead,packageRootOffails to recognise the differently-named root for exactly the reason this finder does, and?? resolvedhands back therequirebuild. The operator gets a load plus the second-instance split #13330 exists to close, silently. That is pinned as today's behaviour by the #15044 suite (BOUNDARY: a link: target whose manifest names something else keeps today's load), which this PR leaves untouched and green. A remedy the runtime honours while making things quietly worse is not one worth printing.The refusal did NOT move
hostImportFailureKind(err) === 'declared-unresolvable'— pinnederr.code === 'MODULE_NOT_FOUND'— pinnedhostImportError('declared-unresolvable', …, cause), the identical call the fall-through below it makeslink:install whose manifest MATCHES the key still loads, silently^1.0.0with a mismatched directory still gets the INSTALL wordingnpm:@fixture/declared@1holding@fixture/installedstill gets the INSTALL wordinglink:declared with NOTHING installed still gets the INSTALL wordingThe predicate is on the declaration, never on the on-disk shape: an unrecognised specifier spelling falls out as "the key is a promise" and keeps today's wording — the conservative direction, matching how
ALIAS_DECLARATION_PROTOCOLSalready defaults.Ablation — the pins can fail
Neutralising
declarationNamesNoPackagetoreturn falserestores the pre-fix behaviour exactly.grep -c= 1; blob hashe2c58a0d45…vs HEAD'sf72b9ca85d…(different, so the edit landed)link:/file:install whose manifest name differs from the key still keeps the wrong INSTALL wording — the location-carrying sub-case #14278 could not reach #15045 wording pins plus the re-worded An aliased install ("foo": "npm:bar@1") fails createHostImporter's manifest-name check, so an ESM-only aliased package keeps the wrong INSTALL wording #14278 BOUNDARY pin. Direction: turned red, as predicted.f72b9ca85d…) and an emptygit diff HEAD, not by an exit code. The script carried atrap … EXIT INT TERMwith absolute paths throughout.Subject resolution:
node.test.tsimports./node.js, a same-package relative specifier vitest resolves tosrc/node.ts, so the ablation exercises source directly — nodist/leg is involved.Changeset — MEASURED, not guessed
@objectstack/typespublishes and this is operator-visible text, soskip-changesetwas tested rather than assumed. Built, thennpm pack --dry-run --json, then every one of the 16 packed paths searched:names a LOCATION, not a package(this PR's new string)dist/node.js,dist/node.mjsThis is an INSTALL problem, not a declaration problem(control, a pre-existing published string)The control fires, so the probe works; the needle is present, so this ships. Changeset written:
@objectstack/types: patch.Clause ② — re-derived from this diff
DeclaredCjsResolveFallbacktype andHostImportFailureKindstill has exactly its three members. The only occurrence of a new identifier in the emitted.d.tsis a{@link unverifiableLocationMessage}inside the JSDoc of an already-exported type — read out atdist/node.d.ts:128, documentation prose, not a declaration.check:dts-closureandcheck:published-filesboth green.Minting a fourth
HostImportFailureKindfor this sub-case was considered and rejected: it would widen a published union for a wording fix. The trade is recorded in the kind's own docblock, including its cost — a consumer that re-words this kind LOCALLY instead of deferring toerr.messagestill prints its own install remedy here (the #14270 class). See acceptance notes.Verification
All at final commit
8e79a6bb3a.pnpm --filter @objectstack/types test— 19 files, 584 tests passedpnpm --filter @objectstack/types typecheck— exit 0. It really does compile the test file:tsc --listFiles | grep -c src/node.test.ts= 1, so this is a measurement, not an assumption.pnpm --filter '@objectstack/types^...' build— exit 0 (needed: an unbuiltpackages/specdist reddens an unrelated pre-existing test in this suite; the first run reproduced exactly that and it is not from this diff)node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after a fetch (no new families), reconciled with--ran: 56 derived, 56 run, 0 UNRUNPREREQUISITE NOT MET, which those gates print as "NOT MEASURED — neither a pass nor a failure":check:dual-build-cjs-loadsandcheck:type-check-debtneed a whole-workspace build (CI'slint.ymlbuilds the closure first).check:lean-entry-closurewas the third — its prerequisite WAS met locally by building@objectstack/objectql, after which it is a real green: "2 published condition(s) measured from a real load."pnpm lint(eslint . --no-inline-config) — the full repo-scoped union, not a narrowing: 6394 files, 0 errors, 0 warnings, exit 0, re-run at8e79a6bb3a.grep -naPover both touched source files returns nothing;pnpm check:nul-bytesgreen.Docs drift — audited, no page edited
The bot listed 13 hand-written pages plus 1 release-owned (
content/docs/releases/v17.mdx, read-only — audited, never edited from a code PR).Same tree, verified per file. ⛔ The bot's own footer says its checkout carried uncommitted changes, so its sha is not a pin. Instead all 14 paths were compared blob-for-blob between this worktree and
16aeaf481c92: 14/14 identical, 0 differing, 0 missing. These are the pages the bot read.Every row is a wrong row, and each was read out rather than counted. All 14 are anchored on string literals, not symbols —
node_modules(a literal indeclaredCjsResolveFallback) andpackage.json(literals inhostInstalledPackageDirandmanifestNameAt). All 27 anchor occurrences across the 14 pages were printed and read. They are: scaffold directory trees (examples,your-first-project,plugins/development,plugins/index,kernel/plugin-spec,objectql/schema), theenginesfield (troubleshooting), oclif plugin config (plugins/index), the emitted manifest's dependencies (cli,upgrading,kernel/index), a wildcard-exclusion list (cli), a CHANGELOG read path (upgrading), the published spec schemas (skills-reference), and — on the release-owned page —@objectstack/cli's sealedexportsmap from #15325. None describes the host importer, its failure kinds, or its refusal text. A grep for host-importer terms across all 14 returns zero, with the same pattern firing 56 times inpackages/types/src/node.tsas its control.The one row that came close, and why it still stands.
content/docs/deployment/tenancy-modes.mdxis the only page in all ofcontent/that quotes host-importer operator text. It reproducesformatOrganizationsAbsentFatalplus the undeclared arm offormatOrganizationsInstallRemedy("declare it in the app's package.json and install"), and adds one sentence about the other arm: "If the package is already declared but the install is broken, the message says so and tells you to repair the install rather than re-read package.json." Not falsified, on two counts: this PR does not touchformatOrganizationsInstallRemedy, so the org path's rendered remedy is byte-identical; and that sentence is scoped to "the install is broken", which is precisely the antecedent this sub-case does not satisfy. It is now mildly incomplete rather than wrong — a third shape exists (declared, installed, not broken, still refused) that the page does not mention — and that incompleteness is the same consequence recorded in the acceptance notes below.The sweep the bot structurally cannot run. Its disclosed input-vs-emitter blind spot means a page quoting the OLD refusal wording would name the words, not
declaredCjsResolveFallback, and could never appear in its list. Sweptcontent/directly for the old message's distinctive strings:content/This is an INSTALL problemre-reading the package.json will not helpdependencies never installeda production prunepoints at a dist that was never builtdeclared-unresolvable/declared-no-loadable-entrycould not be resolvedui/forms.mdx, read out: a REST row for500 LOOKUP_TARGET_MISSING, about a lookup's target object. Unrelated.Cannot find module 'api/client-sdk.mdx, read out: a note that SDK imports on that page used to red with TS2307. Unrelated.Two further sweeps —
link:/file:/ alias-shaped prose, and host-importer / declaration-gate prose — return zero pages across all ofcontent/.Controls, so every zero above is a reading and not a silent miss. The sweep machinery reaches
content/(organization wall is INACTIVE→ 1 hit,tenancy-modes.mdx:237). Both zero-returning patterns fire where their subject lives: 2 files each underpackages/types/src/.What this did and did not answer. It answers that no hand-written page states, quotes or paraphrases the wording this PR changes, and that no page describes what a failed
link:/file:load tells an operator. It does not answer the bot's two disclosed blind spots in general — the 156 unreachable route-ledger rows, and input-stated rules on pages sharing no token with their emitter. The second one is the one that matters on a message diff, which is why it was swept by hand above rather than delegated. ⇒ No documentation page is falsified by this PR, so no page is edited and the declared file surface does not widen.验收备注
formatOrganizationsInstallRemedy(packages/cli/src/commands/serve.ts) mints its own remedy sentence fordeclared-unresolvableinstead of deferring toerr.message, andformatOrganizationsAbsentFatalprints that remedy above thecause:line. On the multi-org path this sub-case would therefore show the corrected cause text beside a stale "Repair the INSTALL" bullet. This is the documented Three failure-kind remedy formatters word the newdeclared-no-loadable-entrykind as "declare it in the app's package.json" — wrong for a declared, installed package #14270 class and is now named in the kind's docblock.link:/file:install whose manifest name differs from the key still keeps the wrong INSTALL wording — the location-carrying sub-case #14278 could not reach #15045's remaining half (the second verification axis) is in exactly this code path, since making these installs load removes the sub-case outright. The two seams inpackages/clithat got it right (formatI18nLoadDiagnostic, and thedeclared-no-loadable-entryarm) interpolate the kind TOKEN only and are correct here already.packages/types/src/node.ts,packages/types/src/node.test.ts, and the changeset."foo": "npm:bar@1") fails createHostImporter's manifest-name check, so an ESM-only aliased package keeps the wrong INSTALL wording #14278 BOUNDARY pin was asserting/INSTALL problem/— i.e. it pinned the wrong wording. It is kept, still asserting the refusal, and now asserts that wording is gone; the replacement wording is pinned in the new Alink:/file:install whose manifest name differs from the key still keeps the wrong INSTALL wording — the location-carrying sub-case #14278 could not reach #15045 suite. The comment records that it changed and why.Generated by Claude Code