@@ -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 ( / I N S T A L L p r o b l e m / ) ;
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 ( / p u b l i s h e s n o e n t r y / ) ;
1373+ expect ( ( err as Error ) . message ) . not . toMatch ( / I N S T A L L p r o b l e m / ) ;
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 ( / I N S T A L L p r o b l e m / ) ;
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 ( / I N S T A L L p r o b l e m / ) ;
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 ( / I N S T A L L p r o b l e m / ) ;
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 ( / I N S T A L L p r o b l e m / ) ;
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