From 2a90bb2a3bdb8a7e21b7613fbaa43e9ae705dc78 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:15:08 +0000 Subject: [PATCH 1/2] wip: #15045 location-install diagnostic --- packages/types/src/node.test.ts | 224 +++++++++++++++++++++++++++++++- packages/types/src/node.ts | 199 ++++++++++++++++++++++++++-- 2 files changed, 411 insertions(+), 12 deletions(-) diff --git a/packages/types/src/node.test.ts b/packages/types/src/node.test.ts index 394683344f..0d0db68aa2 100644 --- a/packages/types/src/node.test.ts +++ b/packages/types/src/node.test.ts @@ -27,7 +27,7 @@ */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import * as NodeModule from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -1472,13 +1472,18 @@ describe('an aliased install is verified against the name its DECLARATION names // direction (refuse, never load the wrong thing) is kept rather than // guessed at — widening it here would make the finder looser than the // manifest-name check exists to be. + // + // ⚠️ This pin asserted the INSTALL wording until #15045. The REFUSAL is + // what #14278 declared and it is unchanged — same kind, same throw; only + // the words changed, because the install this message described was + // already correct. The wording itself is pinned in the #15045 suite below. const root = app('link-mismatch', 'linked-other', 'link:../elsewhere'); installAs(root, 'linked-other', '@fixture/some-other-name', { exports: ESM_ONLY_EXPORTS }, { 'dist/index.js': "export const BUILD = 'other';\n", }); const err = await createHostImporter(root)('linked-other').catch((e: unknown) => e); expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); - expect((err as Error).message).toMatch(/INSTALL problem/); + expect((err as Error).message).not.toMatch(/INSTALL problem/); }); it('TIGHTNESS: an alias naming one package does not license a directory holding another', async () => { @@ -1862,3 +1867,218 @@ exports.BUILD = 'cjs'; expect((await importer(root)('linked-other')).BUILD).toBe('linked-cjs'); }); }); + +/** + * ── #15045: the location sub-case REFUSES correctly and EXPLAINED itself wrongly ─ + * + * #14278 left one sub-case standing on the fallback leg, deliberately and with + * a pin: `link:` / `file:` (and a git or tarball URL) name a LOCATION, not a + * package, so the declaration carries no name for the finder to expect and the + * KEY stays the expectation. A linked package whose manifest says something + * else is therefore refused. + * + * The refusal is right — the alternative is loading a directory the host never + * named. What was wrong is what it SAID. Driven on a real symlinked `link:` + * install, the message read: + * + * This is an INSTALL problem, not a declaration problem ... + * • dependencies never installed ... -> run `pnpm install` in + * • a production prune / filtered deploy dropped it + * • it IS installed but its "main"/"exports" points at a dist that was + * never built + * + * Every one of those is measurably FALSE for this shape: the finder had just + * READ the manifest at `node_modules/`, so the package is on disk, was not + * pruned, and its `import` target exists. The operator runs `pnpm install`, + * nothing changes, and they go hunting for a build that is not broken. + * + * ⛔ What this suite does NOT pin, because it was NOT built: the second + * verification axis the card also proposes (comparing + * `realpath(node_modules/)` against the declared location), which would + * make these installs LOAD. That relaxes the finder's accept set and is a + * contract decision; the card stays open for it. The tests below assert the + * opposite — that the refusal still fires on exactly the inputs it fired on + * before. + */ +describe('a location install whose manifest differs states the LIMIT, not a false remedy (#15045)', () => { + const bases: string[] = []; + + afterAll(() => { + for (const dir of bases) rmSync(dir, { recursive: true, force: true }); + }); + + /** The card's exact shape: `import` condition only, no `require`, no `main`. */ + const ESM_ONLY_EXPORTS = { '.': { import: './dist/index.js' } }; + + /** + * A REAL location install: `node_modules/` is a SYMLINK to a sibling + * directory, which is what `link:` and a directory `file:` actually produce. + * + * #14278's fixtures above install a plain directory instead — correct there, + * since the finder reads `node_modules/` and realpaths only afterwards, + * so both shapes exercise one code path. This card is ABOUT the location + * shape, so it drives the on-disk shape an operator would really have. + */ + function linkedApp( + tag: string, + key: string, + specifier: string, + manifestName: string, + exportsField: unknown = ESM_ONLY_EXPORTS, + ): string { + const base = mkdtempSync(join(tmpdir(), `os-loc-${tag}-`)); + bases.push(base); + const root = join(base, 'app'); + const linked = join(base, 'elsewhere'); + mkdirSync(join(root, 'node_modules'), { recursive: true }); + mkdirSync(join(linked, 'dist'), { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'location-host-fixture', type: 'module', dependencies: { [key]: specifier } }), + 'utf8', + ); + writeFileSync( + join(linked, 'package.json'), + JSON.stringify({ name: manifestName, version: '0.0.0-fixture', type: 'module', exports: exportsField }), + 'utf8', + ); + writeFileSync(join(linked, 'dist', 'index.js'), `export const BUILD = ${JSON.stringify(manifestName)};\n`, 'utf8'); + const at = join(root, 'node_modules', ...key.split('/')); + mkdirSync(dirname(at), { recursive: true }); + symlinkSync(linked, at, 'dir'); + return root; + } + + const refusalFor = async (root: string, spec: string): Promise => + (await createHostImporter(root)(spec).catch((e: unknown) => e)) as Error; + + it('PRECONDITION: the symlinked ESM-only install reaches the fallback at all', () => { + // Without this, everything below could be passing for the wrong reason: + // the CJS resolver must FIND the symlink and refuse on the CONDITION, so + // the fallback inside that catch is what decides. + const root = linkedApp('precondition', 'linked-other', 'link:../elsewhere', '@fixture/some-other-name'); + let code: string | undefined; + try { + createHostRequire(root).resolve('linked-other'); + } catch (e) { + code = (e as { code?: string }).code; + } + expect(code).toBe('ERR_PACKAGE_PATH_NOT_EXPORTED'); + }); + + it('THE CARD: the refusal is UNCHANGED — same kind, same code, still not loaded', async () => { + // The half that must not move. A diff that turned this into a load would + // have left the card's scope whatever its message said. + const root = linkedApp('refusal', 'linked-other', 'link:../elsewhere', '@fixture/some-other-name'); + const err = await refusalFor(root, 'linked-other'); + expect(err).toBeInstanceOf(Error); + expect(hostImportFailureKind(err)).toBe('declared-unresolvable'); + expect((err as unknown as { code?: string }).code).toBe('MODULE_NOT_FOUND'); + }); + + it('THE CARD: the three false remedies are gone', async () => { + // Asserted as ABSENCES of the old message's load-bearing claims, not as + // punctuation. Each was measurably false for this shape. + const root = linkedApp('remedies-gone', 'linked-other', 'link:../elsewhere', '@fixture/some-other-name'); + const { message } = await refusalFor(root, 'linked-other'); + expect(message).not.toMatch(/INSTALL problem, not a declaration problem/); + expect(message).not.toMatch(/dependencies never installed/); + expect(message).not.toMatch(/production prune/); + expect(message).not.toMatch(/points at a dist that was never built/); + }); + + it('THE CARD: it states the MEASUREMENT — what is installed, and what was expected', async () => { + const root = linkedApp('measurement', 'linked-other', 'link:../elsewhere', '@fixture/some-other-name'); + const { message } = await refusalFor(root, 'linked-other'); + // The two names it compared, both present, so the operator can see the + // mismatch rather than infer it. + expect(message).toMatch(/its package\.json is named: "@fixture\/some-other-name"/); + expect(message).toMatch(/this finder expected: "linked-other"/); + // And the directory it read them from. + expect(message).toMatch(/installed at: .*node_modules\/linked-other/); + }); + + it('THE CARD: it names the LIMIT — a location specifier carries no name to expect', async () => { + const root = linkedApp('limit', 'linked-other', 'link:../elsewhere', '@fixture/some-other-name'); + const { message } = await refusalFor(root, 'linked-other'); + expect(message).toMatch(/NOT an install problem/); + expect(message).toMatch(/names a LOCATION, not a package/); + // The card asked for the git / tarball sentence: they carry no on-disk + // location either and land in exactly this sub-case. + expect(message).toMatch(/tarball URL/); + // ⚠️ `pnpm install` IS mentioned, inside the sentence that says it changes + // nothing. Pinned as a PRESENCE so nobody later "fixes" the mention by + // deleting the one line that stops the operator's reflex. + expect(message).toMatch(/re-running `pnpm install`.*change nothing/s); + }); + + it('THE CARD: the remedy it prints WORKS — renaming either end makes it load', async () => { + // The message tells the operator to make the two names agree. Both ends + // are pinned, because a printed remedy nobody measured is the class of + // defect this card is about. + const byKey = linkedApp('remedy-key', '@fixture/some-other-name', 'link:../elsewhere', '@fixture/some-other-name'); + expect((await createHostImporter(byKey)('@fixture/some-other-name')).BUILD).toBe('@fixture/some-other-name'); + + const byManifest = linkedApp('remedy-manifest', 'linked-other', 'link:../elsewhere', 'linked-other'); + expect((await createHostImporter(byManifest)('linked-other')).BUILD).toBe('linked-other'); + }); + + it('NEGATIVE CONTROL: a `link:` install whose manifest MATCHES the key still loads, silently', async () => { + // The load path is untouched. If this ever reddens, the change stopped + // being a wording change. + const root = linkedApp('control', 'linked', 'link:../elsewhere', 'linked'); + expect((await createHostImporter(root)('linked')).BUILD).toBe('linked'); + }); + + it('the same wording covers `file:`, a tarball URL and the bare `owner/repo` shorthand', async () => { + // One fact, four spellings: none of them names a package. `file:` and + // `link:` are the two location protocols; a git or tarball URL names no + // on-disk location at all and installs under the key with whatever the + // published manifest carries. + for (const [tag, specifier] of [ + ['file', 'file:../elsewhere'], + ['tarball', 'https://example.invalid/pkg.tgz'], + ['github', 'github:acme/bar'], + ['shorthand', 'acme/bar'], + ] as const) { + const root = linkedApp(tag, 'located-other', specifier, '@fixture/some-other-name'); + const { message } = await refusalFor(root, 'located-other'); + expect(message, `${specifier} should get the location wording`).toMatch(/NOT an install problem/); + expect(message, `${specifier} should not keep the install remedy`).not.toMatch(/INSTALL problem/); + } + }); + + it('TIGHTNESS: a location declaration with NOTHING installed keeps the INSTALL wording', async () => { + // The split is real, not a blanket re-wording of `link:`. Here the install + // genuinely IS the problem and the old remedies are the right ones. + const base = mkdtempSync(join(tmpdir(), 'os-loc-absent-')); + bases.push(base); + const root = join(base, 'app'); + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'h', type: 'module', dependencies: { 'linked-gone': 'link:../nowhere' } }), + 'utf8', + ); + const { message } = await refusalFor(root, 'linked-gone'); + expect(message).toMatch(/INSTALL problem, not a declaration problem/); + }); + + it('TIGHTNESS: a plain RANGE with a mismatched directory keeps the INSTALL wording', async () => { + // The predicate is on the DECLARATION, not on the on-disk shape: the same + // symlinked mismatch under `^1.0.0` is a registry install that landed + // wrong, and `pnpm install` is exactly the remedy for it. A red here would + // mean the re-wording leaked out of the location sub-case. + const root = linkedApp('range-mismatch', '@fixture/plain-range', '^1.0.0', '@fixture/somebody-else'); + const { message } = await refusalFor(root, '@fixture/plain-range'); + expect(message).toMatch(/INSTALL problem, not a declaration problem/); + }); + + it('TIGHTNESS: an `npm:` alias naming one package still refuses another as an INSTALL fault', async () => { + // `npm:` DOES name a package, so the key is not the expectation and a + // directory holding a third name is a broken install, not a limit. + const root = linkedApp('alias-mismatch', 'aliased', 'npm:@fixture/declared@1', '@fixture/installed'); + const { message } = await refusalFor(root, 'aliased'); + expect(message).toMatch(/INSTALL problem, not a declaration problem/); + }); +}); diff --git a/packages/types/src/node.ts b/packages/types/src/node.ts index 97d373138e..f72b9ca85d 100644 --- a/packages/types/src/node.ts +++ b/packages/types/src/node.ts @@ -292,6 +292,17 @@ export function isDeclaredByHost(specifier: string, hostRoot?: string): boolean * in the app and install. * - `declared-unresolvable` — the app declares it and it still would not * resolve. Remedy: fix the INSTALL. Re-reading the manifest is wasted effort. + * ⚠️ One sub-case under this kind is NOT an install problem and does not say + * it is (#15045): a `link:` / `file:` (or git / tarball) declaration names a + * LOCATION, so the fallback has only the KEY to expect, and a linked manifest + * naming something else is refused with + * {@link unverifiableLocationMessage}'s wording instead. The KIND is shared + * deliberately — the refusal, the `MODULE_NOT_FOUND` code and every consumer + * branch are unchanged; minting a fourth kind would widen a published union + * for a wording fix. ⛔ A consumer that re-words this kind LOCALLY instead of + * deferring to `err.message` therefore still prints its own install remedy + * here — the #14270 class, and the reason both seams in `packages/cli` that + * got it right interpolate the kind TOKEN only. * - `declared-no-loadable-entry` (#14041) — the app declares it, the install * delivered it, and the package's own `exports` names NO entry Node can load * for the requested subpath — no `require`-condition target (which is why the @@ -656,6 +667,60 @@ function declaredManifestName(declaration: HostDeclaration): string { return packageNameFromSpecifier(name) === name ? name : packageName; } +/** + * Declaration value prefixes that name a LOCATION on disk or a REMOTE ARTEFACT + * instead of a package (#15045). + * + * The complement of {@link ALIAS_DECLARATION_PROTOCOLS} on the axis that + * matters to the FALLBACK's diagnostic: an alias protocol names a package, and + * a plain range leaves the KEY naming it — a registry install lands under its + * own name, so a directory holding something else there really is a broken + * install. These do neither. `link:../bar` names a directory whose manifest may + * say anything; a git or tarball URL names no on-disk location at all and + * installs under the key with whatever the published manifest carries. For all + * of them the key is a FALLBACK expectation rather than a promise the host + * made, so a mismatch is the finder's declared limit and NOT an install fault + * — which is the whole difference between the two messages below. + * + * ⚠️ Read for WORDING only. It moves no expectation and licenses no directory: + * {@link hostInstalledPackageDir} refuses exactly what it refused before, and + * the second verification axis the card names (comparing + * `realpath(node_modules/)` against the declared location, which WOULD + * make these load) is deliberately not built here. + * + * An unrecognised spelling falls out as "the key is a promise" and keeps + * today's INSTALL wording — the conservative direction, matching + * {@link ALIAS_DECLARATION_PROTOCOLS}'s own default. + */ +const NAMELESS_DECLARATION_PREFIXES = [ + 'link:', + 'file:', + 'portal:', + 'git:', + 'git+', + 'github:', + 'gitlab:', + 'bitbucket:', + 'gist:', + 'http:', + 'https:', +] as const; + +/** + * Does the host's declaration leave this key's manifest name UNKNOWABLE from + * the declaration alone (#15045)? See {@link NAMELESS_DECLARATION_PREFIXES}. + */ +function declarationNamesNoPackage(declaration: HostDeclaration): boolean { + const { specifier } = declaration; + if (specifier === undefined) return false; + if (NAMELESS_DECLARATION_PREFIXES.some((prefix) => specifier.indexOf(prefix) === 0)) return true; + // npm's protocol-less GitHub shorthand, `/[#]`. It is a + // repository like `github:owner/repo` and carries no name for the same + // reason; no semver range spelling contains a `/`, so the two do not + // overlap. A leading `/` is an absolute path, which is not a shorthand. + return specifier.indexOf('/') > 0 && specifier.indexOf(':') === -1; +} + /** * The directory of the package named `manifestName` that owns `resolvedFile`. * @@ -802,6 +867,13 @@ function esmEntryForDeclared( type DeclaredCjsResolveFallback = /** Not present in the host's own `node_modules` — the install really is the problem. */ | { outcome: 'absent' } + /** + * Present at the key, holding a package named something ELSE, under a + * declaration that names no package to expect (#15045). Refused exactly as + * `absent` is — same kind, same throw — but it is a different measurement and + * gets its own wording: nothing about the install is broken. + */ + | { outcome: 'unverifiable-location'; packageDir: string; installedName: string } /** Rescued: the `import`-condition entry to load. */ | { outcome: 'entry'; entry: string } /** Present, and its manifest names a runtime target — the FILES are the problem. */ @@ -849,6 +921,26 @@ function hasInvalidExportsSubpathSegments(subpath: string): boolean { }); } +/** + * The `name` of the manifest in `dir`, or `undefined` when there is no readable, + * parseable `package.json` there or its `name` is not a string. + * + * ⚠️ Absent and PRESENT-BUT-NAMED-OTHERWISE both answer `undefined` to the + * check that consults it, which is correct — neither is the declared package's + * install. They are different FACTS about the app, though, and #15045 is the + * card about telling an operator which one was measured. + */ +function manifestNameAt(dir: string): string | undefined { + try { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { + name?: unknown; + }; + return typeof manifest.name === 'string' ? manifest.name : undefined; + } catch { + return undefined; + } +} + /** * The one directory the fallback finder consults, verified to hold the * declared package (a `package.json` whose `name` is the one @@ -861,14 +953,10 @@ function hasInvalidExportsSubpathSegments(subpath: string): boolean { function hostInstalledPackageDir(declaration: HostDeclaration): string | undefined { const { packageName, hostRoot } = declaration; const linked = join(hostRoot, 'node_modules', ...packageName.split('/')); - try { - const manifest = JSON.parse(readFileSync(join(linked, 'package.json'), 'utf8')) as { - name?: unknown; - }; - if (manifest.name !== declaredManifestName(declaration)) return undefined; - } catch { - return undefined; - } + // Unreadable, unparseable, or named something else — all `undefined`, exactly + // as before #15045; the CALLER is what now distinguishes them, and only to + // pick the wording. + if (manifestNameAt(linked) !== declaredManifestName(declaration)) return undefined; try { return realpathSync(linked); } catch { @@ -883,9 +971,24 @@ function declaredCjsResolveFallback( specifier: string, declaration: HostDeclaration, ): DeclaredCjsResolveFallback { - const { packageName } = declaration; + const { packageName, hostRoot } = declaration; const packageDir = hostInstalledPackageDir(declaration); - if (packageDir === undefined) return { outcome: 'absent' }; + if (packageDir === undefined) { + // #15045: the finder has REFUSED. Re-read the one directory it consulted so + // the failure can say which of the two absences it measured. A cold error + // path that was already about to build a multi-line message, so the second + // read costs nothing anyone can observe. + const linked = join(hostRoot, 'node_modules', ...packageName.split('/')); + const installedName = manifestNameAt(linked); + if ( + installedName !== undefined && + installedName !== packageName && + declarationNamesNoPackage(declaration) + ) { + return { outcome: 'unverifiable-location', packageDir: linked, installedName }; + } + return { outcome: 'absent' }; + } let exportsField: unknown; try { @@ -928,6 +1031,71 @@ function declaredCjsResolveFallback( return { outcome: 'no-loadable-entry', packageDir }; } +/** + * The wording for {@link DeclaredCjsResolveFallback} `unverifiable-location` + * (#15045) — a `link:` / `file:` (or git / tarball) install whose linked + * manifest names something other than the key. + * + * The refusal it explains is unchanged and deliberate; what changed is that it + * no longer prescribes {@link unresolvableMessage}'s remedies, every one of + * which is measurably false here: the package IS on disk, so it was neither + * "never installed" nor pruned away, and its `import` target exists. An + * operator handed those runs `pnpm install`, watches nothing change, and then + * goes looking for a build that is not broken. + * + * The closing remedy is one fact stated from both ends, and it was MEASURED, + * not reasoned: make the key and the linked manifest's `name` agree — rename + * either — and the key becomes a true expectation, so this same fallback + * rescues the load. + * + * ⛔ Deliberately NOT offered: "have the package publish a `require` + * condition". 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, {@link packageRootOf} fails to recognise the differently-named root + * for the same reason this finder does, and `?? resolved` hands back the + * `require` build. The operator gets a load, plus the second-instance split + * #13330 exists to close, and no warning. A remedy the runtime honours while + * making things quietly worse is not one worth printing. + */ +function unverifiableLocationMessage( + declaration: HostDeclaration, + found: { packageDir: string; installedName: string }, + cause: unknown, +): string { + const { packageName, hostRoot, field, specifier } = declaration; + const { packageDir, installedName } = found; + const detail = cause instanceof Error ? cause.message : String(cause); + return ( + `Cannot load module '${packageName}': the host app DECLARES it ` + + `(${field}: ${JSON.stringify(specifier)}), a package IS installed at that key, and ` + + 'this ESM fallback cannot confirm it is the declared one.\n' + + ` host app: ${hostRoot}\n` + + ` installed at: ${packageDir}\n` + + ` its package.json is named: ${JSON.stringify(installedName)}\n` + + ` this finder expected: ${JSON.stringify(packageName)}\n` + + '\n This is NOT an install problem, and NOT a declaration problem — the package is\n' + + ' on disk and the declaration is right, so re-running `pnpm install`, un-pruning a\n' + + ' deploy and rebuilding a dist all change nothing here.\n' + + ' What it IS: a "link:" / "file:" declaration names a LOCATION, not a package. The\n' + + ' manifest at the other end may carry any name, and the specifier holds none for\n' + + ' this finder to expect, so the KEY is all it has to check against. A git or\n' + + ' tarball URL (github:owner/repo, https://.../pkg.tgz) names no on-disk location\n' + + ' either and lands here the same way.\n' + + ' The refusal is deliberate: this fallback stays strictly tighter than the\n' + + ' CommonJS resolution it backs up, and will not load a directory it cannot tie to\n' + + ' the declaration. Only a package publishing no `require` condition reaches it at\n' + + ' all, so nothing that loads today is affected either way.\n' + + ' What DOES change it — make the two names AGREE, from whichever end you own:\n' + + ` • declare the linked package under its own name: key ${JSON.stringify(installedName)},\n` + + ' pointing at the same location, and import it under that name\n' + + ` • or set the linked package's own "name" to ${JSON.stringify(packageName)}, if that\n` + + ' directory is yours to edit\n' + + ' Either way the key becomes the expectation this finder checks, and the load\n' + + ' succeeds through this same fallback.\n' + + ` (resolver: ${detail})` + ); +} + function noLoadableEntryMessage( declaration: HostDeclaration, packageDir: string, @@ -1072,6 +1240,17 @@ export function createHostImporter( if (fallback.outcome === 'entry') { return import(pathToFileURL(fallback.entry).href); } + if (fallback.outcome === 'unverifiable-location') { + // #15045: the SAME kind and the SAME throw as every other unrescued + // outcome below — this branch decides WORDING only. Turning this into + // a load is the second verification axis the card holds open, and is + // a contract change, not a diagnostic one. + throw hostImportError( + 'declared-unresolvable', + unverifiableLocationMessage(declaration, fallback, cause), + cause, + ); + } if (fallback.outcome === 'no-loadable-entry') { throw hostImportError( 'declared-no-loadable-entry', From 8e79a6bb3ad66986f50983fa734922f2170c03da Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 02:57:29 +0000 Subject: [PATCH 2/2] fix(types): a location install's refusal states the LIMIT, not a false install remedy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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/`, 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/)` against the declared location, which would make these installs LOAD — is deliberately not built here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --- .../host-importer-location-install-diagnostic.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/host-importer-location-install-diagnostic.md diff --git a/.changeset/host-importer-location-install-diagnostic.md b/.changeset/host-importer-location-install-diagnostic.md new file mode 100644 index 0000000000..e5c358a01d --- /dev/null +++ b/.changeset/host-importer-location-install-diagnostic.md @@ -0,0 +1,11 @@ +--- +"@objectstack/types": patch +--- + +`createHostImporter` stops prescribing an install repair for a `link:` / `file:` install that is already correct. The refusal is unchanged; only its wording is. + +A host app declaring `{"foo": "link:../bar"}` links `node_modules/foo` to a directory whose manifest may be named anything. `link:`, `file:` and git or tarball URLs name a LOCATION or a remote artefact, never a package, so the specifier carries no name for the ESM-only fallback finder to expect and the KEY stays the expectation — kept deliberately, because widening it would accept any directory sitting at the key and trade a wrong REMEDY for a wrong LOAD. When the linked manifest names something else the finder therefore refuses, and it was reporting that refusal with the `declared-unresolvable` INSTALL wording: run `pnpm install`, check a production prune did not drop it, check the dist was built. Driven on a real symlinked install, all three are measurably false — the finder had just read the manifest at `node_modules/foo`, so the package is on disk, was not pruned, and its `import` target exists. The operator reinstalls, nothing changes, and they go looking for a build that is not broken. + +That sub-case now states what was actually measured: the directory it consulted, the name the manifest there carries, the name it expected, and why a location specifier leaves it with only the key. It says outright that this is neither an install nor a declaration problem, and closes with the remedy that does work — make the two names agree, by declaring the linked package under its own name or by renaming the linked manifest to the key. Both ends are pinned as loading. + +Unchanged: the refusal itself, its `declared-unresolvable` kind, its `MODULE_NOT_FOUND` code and every consumer branch that reads them; the finder's accept set, which is byte-for-byte what it was — a `link:` install whose manifest matches the key still loads silently, and a plain range or an `npm:` alias whose directory holds a different package still gets the INSTALL wording, because there the install really is the fault. The second verification axis that would make these installs LOAD (comparing `realpath(node_modules/)` against the declared location) is deliberately not built here.