Skip to content

Commit 101ad2c

Browse files
os-trumpclaude
andauthored
fix(types): find an aliased install through the host importer's ESM-only fallback (#15047)
`createHostImporter`'s #14041 fallback finder verifies the one directory it consults — `<hostRoot>/node_modules/<key>` — by matching that directory's manifest `name` against the declared package name. An aliased install fails that check by construction: `{ "dependencies": { "foo": "npm:bar@1" } }` puts a manifest named `bar` at `node_modules/foo`, so the finder answered `absent` and an ESM-only aliased package kept the pre-#14041 INSTALL wording — a confidently-wrong remedy against an install that is already correct. The declaration is now parsed for the name it promises. `npm:bar@1`, `npm:@acme/x@^2` and the aliased `workspace:bar@*` name the package installed under the key; that is the manifest name the finder expects there. The manifest-name check itself is NOT loosened — it is what keeps the fallback strictly tighter than the CJS resolution it backs up (#4719 from the fallback side). What moves is the EXPECTATION, still authored by the host and still read out of the host's own package.json. A value that is not a bare package name — a `workspace:` range, an alias carrying a subpath — yields no expectation to move to, so the key stays and today's refusal is kept; `link:` and `file:` name a LOCATION rather than a package, so no name is derivable from them at all. Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 8a96e66 commit 101ad2c

3 files changed

Lines changed: 368 additions & 7 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/types": patch
3+
---
4+
5+
fix(types): an aliased install (`"foo": "npm:bar@1"`) is now found by the host importer's ESM-only fallback
6+
7+
`createHostImporter`'s #14041 fallback finder verifies the one directory it
8+
consults — `<hostRoot>/node_modules/<key>` — by matching that directory's
9+
`package.json` `name` against the declared package name. An aliased install
10+
fails that check by construction: `{ "dependencies": { "foo": "npm:bar@1" } }`
11+
puts a manifest named `bar` at `node_modules/foo`. The finder answered
12+
`absent`, and an ESM-only aliased package therefore kept the pre-#14041 INSTALL
13+
wording — a confidently-wrong remedy sending an operator to run `pnpm install`
14+
against an install that is already correct, on a declaration shape
15+
`packageNameFromSpecifier`'s own documentation blesses.
16+
17+
The declaration is now parsed for the name it promises: `npm:bar@1`,
18+
`npm:@acme/x@^2` and the aliased `workspace:bar@*` name the package installed
19+
under the key, so that is the manifest name the finder expects there. An
20+
aliased ESM-only package is rescued exactly as a plain one is, and an aliased
21+
install publishing nothing loadable gets the message about the PACKAGE's own
22+
shape instead of the INSTALL message.
23+
24+
⚠️ The manifest-name check itself is NOT loosened — that check is what keeps
25+
the fallback strictly tighter than the CJS resolution it backs up (#4719's
26+
declaration gate, from the fallback side). What moved is the EXPECTATION, still
27+
authored by the host and still read out of the host's own `package.json`: an
28+
alias naming one package refuses a directory holding another, a non-aliased
29+
declaration is unchanged, and a value that is not a bare package name — a
30+
`workspace:` range, an alias carrying a subpath — yields no expectation to move
31+
to, so the key stays and today's refusal is kept. `link:` and `file:` name a
32+
LOCATION rather than a package, so no name is derivable from them at all; they
33+
keep the key expectation, and with it the conservative direction the finder had
34+
before.

packages/types/src/node.test.ts

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,3 +1243,274 @@ describe('the declared leg loads an ESM-only package via a hostRoot node_modules
12431243
expect((err as Error).message).toMatch(/INSTALL problem/);
12441244
});
12451245
});
1246+
1247+
/**
1248+
* ── #14278: an ALIASED install names its own package, and the finder must know ─
1249+
*
1250+
* `{ "dependencies": { "foo": "npm:bar@1" } }` installs the package `bar` at
1251+
* `<hostRoot>/node_modules/foo`: the manifest there is named `bar`, while the
1252+
* importable specifier — and the declaration key — is `foo`. The #14041
1253+
* fallback finder verifies the directory it consults by matching that
1254+
* manifest's `name` against the declared name, so it refused every aliased
1255+
* install BY CONSTRUCTION, and an ESM-only aliased package kept the
1256+
* pre-#14041 INSTALL wording: a confidently-wrong remedy against an install
1257+
* that is already correct.
1258+
*
1259+
* The fix parses the DECLARATION, never the directory. The host's own
1260+
* `package.json` says which package `foo` is an alias for, so the expectation
1261+
* is still authored by the host and the check is exactly as tight as it was —
1262+
* what moves is the EXPECTED NAME, never the comparison. The TIGHTNESS cases
1263+
* below are that proof: an alias naming one package does not license a
1264+
* directory holding another, and a NON-aliased declaration is untouched (the
1265+
* `manifest NAMES the declared package` case above is that control, and it
1266+
* stays green).
1267+
*
1268+
* `link:` / `file:` name a LOCATION rather than a package, so no name can be
1269+
* derived from them at all; they keep the key expectation, and with it today's
1270+
* conservative refusal.
1271+
*/
1272+
describe('an aliased install is verified against the name its DECLARATION names (#14278)', () => {
1273+
/** The card's exact shape: `import` condition only, no `require`, no `main`. */
1274+
const ESM_ONLY_EXPORTS = { '.': { import: './dist/index.js' } };
1275+
1276+
const roots: string[] = [];
1277+
1278+
afterAll(() => {
1279+
for (const dir of roots) rmSync(dir, { recursive: true, force: true });
1280+
});
1281+
1282+
/** A fresh host app declaring `key` with the literal specifier under test. */
1283+
function app(tag: string, key: string, specifier: string): string {
1284+
const root = mkdtempSync(join(tmpdir(), `os-aliased-${tag}-`));
1285+
roots.push(root);
1286+
writeFileSync(
1287+
join(root, 'package.json'),
1288+
JSON.stringify({
1289+
name: 'aliased-host-fixture',
1290+
type: 'module',
1291+
dependencies: { [key]: specifier },
1292+
}),
1293+
'utf8',
1294+
);
1295+
return root;
1296+
}
1297+
1298+
/**
1299+
* Install a package NAMED `manifestName` at `node_modules/<key>` — the
1300+
* on-disk shape every aliasing package manager produces. (`link:` /
1301+
* `workspace:` installs put a SYMLINK there instead; the finder reads
1302+
* `node_modules/<key>` either way and realpaths only afterwards, so a plain
1303+
* directory exercises the same code.)
1304+
*/
1305+
function installAs(
1306+
root: string,
1307+
key: string,
1308+
manifestName: string,
1309+
manifest: Record<string, unknown>,
1310+
files: Record<string, string>,
1311+
): void {
1312+
const dir = join(root, 'node_modules', ...key.split('/'));
1313+
mkdirSync(dir, { recursive: true });
1314+
writeFileSync(
1315+
join(dir, 'package.json'),
1316+
JSON.stringify({
1317+
name: manifestName,
1318+
version: '0.0.0-fixture',
1319+
type: 'module',
1320+
...manifest,
1321+
}),
1322+
'utf8',
1323+
);
1324+
for (const rel of Object.keys(files)) {
1325+
const target = join(dir, rel);
1326+
mkdirSync(dirname(target), { recursive: true });
1327+
writeFileSync(target, files[rel] as string, 'utf8');
1328+
}
1329+
}
1330+
1331+
it('PRECONDITION: an aliased ESM-only install reaches the fallback at all', () => {
1332+
// Same precondition the #14041 suite pins, re-measured through an alias:
1333+
// the CJS resolver FINDS `node_modules/aliased` and refuses on the
1334+
// CONDITION, so everything below is decided inside that throw's catch —
1335+
// the fallback is the only thing that can answer, and before this fix it
1336+
// answered `absent`.
1337+
const root = app('precondition', 'aliased', 'npm:@fixture/alias-target@1');
1338+
installAs(root, 'aliased', '@fixture/alias-target', { exports: ESM_ONLY_EXPORTS }, {
1339+
'dist/index.js': "export const BUILD = 'aliased-esm-only';\n",
1340+
});
1341+
let code: string | undefined;
1342+
try {
1343+
createHostRequire(root).resolve('aliased');
1344+
} catch (e) {
1345+
code = (e as { code?: string }).code;
1346+
}
1347+
expect(code).toBe('ERR_PACKAGE_PATH_NOT_EXPORTED');
1348+
});
1349+
1350+
it('THE CARD: an aliased ESM-only package is rescued, not reported as an INSTALL problem', async () => {
1351+
const root = app('loads', 'aliased-esm', 'npm:@fixture/alias-esm-only@1');
1352+
installAs(root, 'aliased-esm', '@fixture/alias-esm-only', { exports: ESM_ONLY_EXPORTS }, {
1353+
'dist/index.js': "export const BUILD = 'aliased-esm-only';\n",
1354+
});
1355+
expect((await createHostImporter(root)('aliased-esm')).BUILD).toBe('aliased-esm-only');
1356+
});
1357+
1358+
it('THE CARD (wording): an aliased install with no loadable entry gets the PACKAGE message', async () => {
1359+
// The card's named deliverable: the aliased install answers with the
1360+
// ESM-only wording (`declared-no-loadable-entry`) instead of the INSTALL
1361+
// wording, because the install is fine and no install action can help.
1362+
const root = app('types-only', 'aliased-types', 'npm:@fixture/alias-types-only@1');
1363+
installAs(
1364+
root,
1365+
'aliased-types',
1366+
'@fixture/alias-types-only',
1367+
{ exports: { '.': { types: './dist/index.d.ts' } } },
1368+
{ 'dist/index.d.ts': 'export declare const BUILD: string;\n' },
1369+
);
1370+
const err = await createHostImporter(root)('aliased-types').catch((e: unknown) => e);
1371+
expect(hostImportFailureKind(err)).toBe('declared-no-loadable-entry');
1372+
expect((err as Error).message).toMatch(/publishes no entry/);
1373+
expect((err as Error).message).not.toMatch(/INSTALL problem/);
1374+
});
1375+
1376+
it('a SCOPED key aliasing an unscoped package is rescued too', async () => {
1377+
// Both halves of the mapping are free to be scoped or not: the key is a
1378+
// directory path under `node_modules`, the alias target is a package name.
1379+
const root = app('scoped-key', '@app/aliased', 'npm:alias-unscoped@^2.0.0');
1380+
installAs(root, '@app/aliased', 'alias-unscoped', { exports: ESM_ONLY_EXPORTS }, {
1381+
'dist/index.js': "export const BUILD = 'alias-unscoped';\n",
1382+
});
1383+
expect((await createHostImporter(root)('@app/aliased')).BUILD).toBe('alias-unscoped');
1384+
});
1385+
1386+
it('an aliased SUBPATH resolves against the aliased package', async () => {
1387+
const root = app('subpath', 'aliased-sub', 'npm:@fixture/alias-subpaths@1');
1388+
installAs(
1389+
root,
1390+
'aliased-sub',
1391+
'@fixture/alias-subpaths',
1392+
{ exports: { '.': { import: './dist/index.js' }, './plugin': { import: './dist/plugin.js' } } },
1393+
{
1394+
'dist/index.js': "export const WHERE = 'root';\n",
1395+
'dist/plugin.js': "export const WHERE = 'plugin';\n",
1396+
},
1397+
);
1398+
expect((await createHostImporter(root)('aliased-sub/plugin')).WHERE).toBe('plugin');
1399+
});
1400+
1401+
it('an alias with no version range names its target just the same', async () => {
1402+
const root = app('no-range', 'aliased-bare', 'npm:@fixture/alias-bare');
1403+
installAs(root, 'aliased-bare', '@fixture/alias-bare', { exports: ESM_ONLY_EXPORTS }, {
1404+
'dist/index.js': "export const BUILD = 'alias-bare';\n",
1405+
});
1406+
expect((await createHostImporter(root)('aliased-bare')).BUILD).toBe('alias-bare');
1407+
});
1408+
1409+
it('a `workspace:` ALIAS names its target; a plain `workspace:` range does not', async () => {
1410+
// pnpm spells an aliased workspace dependency `workspace:<name>@<range>`;
1411+
// `workspace:*` / `workspace:^1.2.3` carry a RANGE only, so the key stays
1412+
// the expected name.
1413+
const aliased = app('workspace-alias', 'ws-aliased', 'workspace:@fixture/ws-target@*');
1414+
installAs(aliased, 'ws-aliased', '@fixture/ws-target', { exports: ESM_ONLY_EXPORTS }, {
1415+
'dist/index.js': "export const BUILD = 'ws-target';\n",
1416+
});
1417+
expect((await createHostImporter(aliased)('ws-aliased')).BUILD).toBe('ws-target');
1418+
1419+
const plain = app('workspace-plain', '@fixture/ws-plain', 'workspace:*');
1420+
installAs(plain, '@fixture/ws-plain', '@fixture/ws-plain', { exports: ESM_ONLY_EXPORTS }, {
1421+
'dist/index.js': "export const BUILD = 'ws-plain';\n",
1422+
});
1423+
expect((await createHostImporter(plain)('@fixture/ws-plain')).BUILD).toBe('ws-plain');
1424+
});
1425+
1426+
it('a `link:` specifier names a LOCATION, so the KEY stays the expected name', async () => {
1427+
// The linked package installed under its own key loads, exactly as before.
1428+
const root = app('link-ok', 'linked', 'link:../linked');
1429+
installAs(root, 'linked', 'linked', { exports: ESM_ONLY_EXPORTS }, {
1430+
'dist/index.js': "export const BUILD = 'linked';\n",
1431+
});
1432+
expect((await createHostImporter(root)('linked')).BUILD).toBe('linked');
1433+
});
1434+
1435+
it('BOUNDARY: a `link:` target whose manifest names something else keeps the refusal', async () => {
1436+
// Deliberate, and the reason `link:` is not "parsed" into a name: a path
1437+
// specifier carries no package name for the finder to expect, so there is
1438+
// nothing to verify a differing manifest against. The conservative
1439+
// direction (refuse, never load the wrong thing) is kept rather than
1440+
// guessed at — widening it here would make the finder looser than the
1441+
// manifest-name check exists to be.
1442+
const root = app('link-mismatch', 'linked-other', 'link:../elsewhere');
1443+
installAs(root, 'linked-other', '@fixture/some-other-name', { exports: ESM_ONLY_EXPORTS }, {
1444+
'dist/index.js': "export const BUILD = 'other';\n",
1445+
});
1446+
const err = await createHostImporter(root)('linked-other').catch((e: unknown) => e);
1447+
expect(hostImportFailureKind(err)).toBe('declared-unresolvable');
1448+
expect((err as Error).message).toMatch(/INSTALL problem/);
1449+
});
1450+
1451+
it('TIGHTNESS: an alias naming one package does not license a directory holding another', async () => {
1452+
// The check moved its EXPECTATION, not its strictness. The declaration
1453+
// says this directory holds `@fixture/alias-declared`; it holds
1454+
// `@fixture/alias-installed`, so it is not the declared package's install
1455+
// and must not be rescued from.
1456+
const root = app('alias-mismatch', 'aliased-wrong', 'npm:@fixture/alias-declared@1');
1457+
installAs(root, 'aliased-wrong', '@fixture/alias-installed', { exports: ESM_ONLY_EXPORTS }, {
1458+
'dist/index.js': "export const BUILD = 'imposter';\n",
1459+
});
1460+
const err = await createHostImporter(root)('aliased-wrong').catch((e: unknown) => e);
1461+
expect(hostImportFailureKind(err)).toBe('declared-unresolvable');
1462+
expect((err as Error).message).toMatch(/INSTALL problem/);
1463+
});
1464+
1465+
it('TIGHTNESS: a NON-aliased declaration is unchanged — the key is still the expected name', async () => {
1466+
// The control the card names: an aliased-install red that also reddens
1467+
// this one would mean the finder got looser, not smarter. A plain range
1468+
// declares no alias, so a directory holding a different package is refused
1469+
// exactly as it was before #14278.
1470+
const root = app('plain-range', '@fixture/plain-range', '^1.0.0');
1471+
installAs(root, '@fixture/plain-range', '@fixture/somebody-else', { exports: ESM_ONLY_EXPORTS }, {
1472+
'dist/index.js': "export const BUILD = 'imposter';\n",
1473+
});
1474+
const err = await createHostImporter(root)('@fixture/plain-range').catch((e: unknown) => e);
1475+
expect(hostImportFailureKind(err)).toBe('declared-unresolvable');
1476+
expect((err as Error).message).toMatch(/INSTALL problem/);
1477+
});
1478+
1479+
it('TIGHTNESS: an alias target carrying a SUBPATH is not a package name, and is refused', async () => {
1480+
// `npm:` values are `<name>[@<range>]` — never a subpath. A value that is
1481+
// not a bare package name yields no expectation to move to, so the key
1482+
// stays, and this directory (named for the subpath's package) is refused.
1483+
const root = app('alias-subpath-value', 'aliased-bad', 'npm:@fixture/alias-bad/deep@1');
1484+
installAs(root, 'aliased-bad', '@fixture/alias-bad', { exports: ESM_ONLY_EXPORTS }, {
1485+
'dist/index.js': "export const BUILD = 'imposter';\n",
1486+
});
1487+
const err = await createHostImporter(root)('aliased-bad').catch((e: unknown) => e);
1488+
expect(hostImportFailureKind(err)).toBe('declared-unresolvable');
1489+
expect((err as Error).message).toMatch(/INSTALL problem/);
1490+
});
1491+
1492+
it('TIGHTNESS: an alias does not reopen the hostRoot boundary', async () => {
1493+
// Every other axis of the finder's tightness is unaffected by the alias:
1494+
// the one directory consulted is still `<hostRoot>/node_modules/<key>`,
1495+
// never a parent's. Installed one level up, under the same key and the
1496+
// aliased name, it is still not this app's install.
1497+
const parent = mkdtempSync(join(tmpdir(), 'os-aliased-parent-'));
1498+
roots.push(parent);
1499+
installAs(parent, 'aliased-up', '@fixture/alias-parent', { exports: ESM_ONLY_EXPORTS }, {
1500+
'dist/index.js': "export const BUILD = 'from-parent';\n",
1501+
});
1502+
const root = join(parent, 'app');
1503+
mkdirSync(root, { recursive: true });
1504+
writeFileSync(
1505+
join(root, 'package.json'),
1506+
JSON.stringify({
1507+
name: 'nested-aliased-host-fixture',
1508+
type: 'module',
1509+
dependencies: { 'aliased-up': 'npm:@fixture/alias-parent@1' },
1510+
}),
1511+
'utf8',
1512+
);
1513+
const err = await createHostImporter(root)('aliased-up').catch((e: unknown) => e);
1514+
expect(hostImportFailureKind(err)).toBe('declared-unresolvable');
1515+
});
1516+
});

0 commit comments

Comments
 (0)