diff --git a/.changeset/subpath-pattern-replacement.md b/.changeset/subpath-pattern-replacement.md new file mode 100644 index 0000000000..04542152b6 --- /dev/null +++ b/.changeset/subpath-pattern-replacement.md @@ -0,0 +1,5 @@ +--- +'@endo/compartment-mapper': minor +--- + +Add support for Node.js subpath pattern replacement in `package.json` `exports` and `imports` fields. Patterns like `"./features/*.js": "./src/features/*.js"` and `"#internal/*.js": "./lib/*.js"` are now resolved at link time using prefix/suffix string matching with specificity ordering. Null-target patterns exclude matching specifiers. Conditional pattern values are resolved through the standard condition-matching rules. Patterns are expanded to concrete module entries during archiving. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 190eb16888..072d7ef1d5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,12 @@ then update the resulting README.md, package.json (specifically setting `description` and [if appropriate] removing `"private": false`), index.js, and index.test.js files. +### Coding Style + +- Prefer `/** @import */` over dynamic `import()` in JSDoc type annotations. + Use a top-level `/** @import {Foo} from 'bar' */` comment instead of inline + `{import('bar').Foo}` in `@param`, `@type`, or `@returns` tags. + ### Markdown Style Guide When writing Markdown documentation: diff --git a/packages/compartment-mapper/designs/subpath-pattern-replacement.md b/packages/compartment-mapper/designs/subpath-pattern-replacement.md new file mode 100644 index 0000000000..a0711b5d76 --- /dev/null +++ b/packages/compartment-mapper/designs/subpath-pattern-replacement.md @@ -0,0 +1,271 @@ +# Subpath Pattern Replacement + +## Objective + +Achieve parity with Node.js subpath pattern replacement for the `exports` +and `imports` fields of `package.json`. +Node.js specifies this behavior in the +[Packages](https://nodejs.org/api/packages.html) documentation under +"Subpath patterns." + +## Node.js Semantics + +In Node.js, the `*` wildcard in subpath patterns is a **string replacement +token**, not a glob. +All instances of `*` on the right side of a pattern are replaced with the +text matched by `*` on the left side. +`*` **matches across `/` separators** — it is not limited to a single +path segment. + +```json +{ + "exports": { + "./features/*.js": "./src/features/*.js" + } +} +``` + +```js +import x from 'pkg/features/x.js'; +// resolves to ./src/features/x.js + +import y from 'pkg/features/y/y.js'; +// resolves to ./src/features/y/y.js +// * matched "y/y", which spans a "/" separator +``` + +The same semantics apply to the `imports` field, where keys begin with `#`: + +```json +{ + "imports": { + "#internal/*.js": "./src/internal/*.js" + } +} +``` + +```js +import z from '#internal/z.js'; +// resolves to ./src/internal/z.js +``` + +### Rules + +1. **One `*` per side.** Node.js allows exactly one `*` in each pattern + key and one `*` in each pattern value. + Having zero `*` on one side and one on the other is an error. +2. **`*` matches any substring**, including substrings that contain `/`. +3. **Exact entries take precedence** over pattern entries. + If both `"./foo"` and `"./*"` exist, `"./foo"` wins. +4. **Pattern specificity.** When multiple patterns could match, Node.js + selects the pattern with the longest matching prefix before the `*`. +5. **Null targets** can exclude subpaths: + `"./features/private/*": null` prevents resolution into that subtree + even if a broader pattern would match. +6. **Conditional patterns.** Pattern values can be condition objects, + following the same condition-matching rules as non-pattern exports. +7. **No `**` (globstar).** Subpath patterns do not support globstar. + Globstar entries are silently ignored. + +## Implementation + +### Pattern Matching (`src/pattern-replacement.js`) + +`makeMultiSubpathReplacer` accepts an array of `PatternDescriptor` +entries and returns a `SubpathReplacer` function. + +Exact entries (no `*`) are stored in a `Map` for O(1) lookup. +Wildcard entries are decomposed into prefix/suffix pairs and sorted by +prefix length descending. +Matching proceeds by checking exact entries first, then trying wildcard +entries in specificity order. +The first wildcard whose prefix and suffix match the specifier wins. +The captured substring between prefix and suffix is substituted into +the replacement template. + +Null-target patterns (`to: null`) match normally but return +`{ result: null }` to signal exclusion. + +### Inference from `package.json` (`src/infer-exports.js`) + +`inferExportsAliasesAndPatterns` processes the `exports` and `imports` +fields and separates entries into: + +- **Concrete aliases** (no `*`) — added to `externalAliases` or + `internalAliases`. +- **Wildcard patterns** (contain `*`) — added to the `patterns` array + as `PatternDescriptor` entries. +- **Null-target patterns** (wildcard key, `null` value) — added to the + `patterns` array with `to: null`. +- **Globstar entries** (`**`) — silently skipped. + +Conditional pattern values (condition objects) are resolved by +`interpretExports` recursively before yielding, so pattern entries +arrive as already-resolved strings. + +`interpretImports` handles the `imports` field with the same logic, +restricted to `#`-prefixed keys. + +### Compartment Map Representation + +An optional `patterns` array on `PackageCompartmentDescriptor` holds +the extracted wildcard patterns: + +```ts +interface PatternDescriptor { + from: string; // e.g., "./*.js" + to: string | null; // e.g., "./src/*.js", or null for exclusion + compartment?: string; // foreign compartment for dependency patterns +} +``` + +Patterns are stored separately from concrete module aliases because they +require runtime resolution (the full set of matching modules is not known +statically). + +### Cross-Package Pattern Propagation (`src/node-modules.js`) + +When building the compartment map, export patterns from dependency +packages are propagated to dependee compartments. +If `patterns-lib` declares: + +```json +{ "exports": { "./features/*.js": "./src/features/*.js" } } +``` + +Then `app` (which depends on `patterns-lib`) receives a pattern entry: + +``` +{ from: "patterns-lib/features/*.js", to: "./src/features/*.js", compartment: "" } +``` + +This allows `import 'patterns-lib/features/alpha.js'` to resolve via +pattern matching in `app`'s `moduleMapHook`, targeting the `patterns-lib` +compartment. + +Import patterns (starting with `#`) are **not** propagated — they are +internal to the declaring package. + +### Pattern Resolution at Link Time (`src/link.js`) + +The `moduleMapHook` resolves specifiers in this order: + +1. **Concrete module descriptors** (exact matches, highest priority). +2. **Patterns** (wildcard replacement). +3. **Scope descriptors** (package-scope resolution, lowest priority). + +When a pattern matches, the resolved path is written back into +`moduleDescriptors` as a concrete entry (with `__createdBy: 'link-pattern'`). +This write-back serves three purposes: caching subsequent imports of the +same specifier, enabling policy enforcement (which checks `modules[specifier]`), +and capturing the expansion for archival. + +Null-target matches throw an error, preventing resolution even if a +scope descriptor would match. + +Cross-compartment patterns resolve to the dependency's compartment via +the `compartment` field on the `PatternDescriptor`. + +Policy enforcement via `enforcePolicyByModule` runs after the write-back +so the specifier is visible in `modules`. + +### Archiving + +Patterns are removed from the compartment map during archiving. +`digestCompartmentMap` constructs result objects with only the fields +recognized by the Agoric chain runtime — `patterns` is never included. +All pattern-matched modules that were actually used are captured as +concrete module descriptors via the write-back in `link.js`. + +Type-level enforcement: `DigestedCompartmentDescriptor` has +`patterns: never`. + +## Eschewed Alternatives + +**Per-segment matching via prefix tree.** +An earlier approach split specifiers on `/` and matched `*` within a +single path segment using a prefix tree. +This did not match Node.js semantics, where `*` spans `/` boundaries. +Prefix/suffix string matching on the full specifier is simpler and +correct. + +**Array fallback values.** +Node.js allows array values in exports as fallback lists, where each +entry is tried in order and the first file that exists on disk is used. +Pattern resolution in the compartment-mapper is a pure string operation +with no filesystem access. +Array fallbacks would require threading read powers through the pattern +matcher and changing the `SubpathReplacer` signature. +Node.js documentation discourages array fallbacks. +If a pattern value is an array, `interpretExports` yields all elements +as separate entries and the first match wins without fallback probing. + +## Testing + +### Parity Strategy + +Each fixture is exercised by both Node.js and the Compartment Mapper. +Assertions are shared via `_subpath-patterns-assertions.js`, so parity +is verified by construction: if both test suites pass, the behaviors +are equivalent. + +- `subpath-patterns-node-parity.test.js` runs fixtures under plain + Node.js using dynamic `import()`. +- `subpath-patterns-node-condition.node-condition.test.js` runs under + `--conditions=blue-moon` via ses-ava (`nodeArguments: ['-C', 'blue-moon']` + in `_ava-node-condition.config.js`). +- `subpath-patterns.test.js` runs fixtures through the `scaffold()` + harness, exercising `loadLocation`, `importLocation`, `makeArchive`, + `parseArchive`, `writeArchive`, `loadArchive`, and `importArchive`. + +### Unit Tests (`pattern-replacement.test.js`) + +13 tests covering: exact match, single-segment wildcard, cross-`/` +matching, specificity ordering, `#`-imports patterns, null-target +exclusion, globstar rejection, wildcard count mismatch, and various +input formats (tuples, `PatternDescriptor` array, record object). + +### Fixture: `fixtures-package-imports-exports` + +Primary fixture for cross-package subpath patterns. + +#### Packages + +- **`patterns-lib`** — exports with `*` patterns, an exact entry, + a null-target exclusion, specificity ordering, and `#`-imports. +- **`cond-patterns-lib`** — conditional pattern: + `"./things/*.js": { "blue-moon": "./src/blue/*.js", "default": "./src/default/*.js" }`. +- **`multi-star-lib`** — multi-`*` pattern (silently ignored by Node.js). +- **`globstar-lib`** — globstar pattern (silently ignored by Node.js). +- **`app`** — entry package that imports from all of the above. + +#### Cases Covered + +| Case | Specifier | Resolves to | +|------|-----------|-------------| +| Single-segment match | `patterns-lib/features/alpha.js` | `./src/features/alpha.js` | +| Cross-separator match | `patterns-lib/features/beta/gamma.js` | `./src/features/beta/gamma.js` | +| Exact over pattern | `patterns-lib/features/beta/exact` | `./src/features/beta/exact-target.js` | +| Imports pattern | `#internal/helper.js` | `./src/internal/helper.js` | +| Specificity | `patterns-lib/utils/private/thing.js` | `./src/private/thing.js` | +| Null-target exclusion | `patterns-lib/features/secret/data.js` | throws | +| Conditional (blue-moon) | `cond-patterns-lib/things/widget.js` | `./src/blue/widget.js` | +| Conditional (default) | `cond-patterns-lib/things/widget.js` | `./src/default/widget.js` | +| Multi-star | `multi-star-lib/x/foo/y/bar/z.js` | silently ignored | +| Globstar | `globstar-lib/deep/nested/thing.js` | silently ignored | + +### Integration Tests (`subpath-patterns.test.js`) + +- Scaffold tests through all execution paths. +- Pattern stripping: inspects archived `compartment-map.json` and + asserts no compartment has a `patterns` property. +- Policy: verifies pattern-matched imports are allowed when the package + is permitted by policy and rejected when not. +- Conditional: verifies user-specified condition selects the correct + branch, and omitting it falls back to `"default"`. +- Null-target: verifies the exclusion throws. + +### Fixture: `fixtures-export-patterns` + +Exercises self-referencing export patterns and `#`-imports within a +single package. diff --git a/packages/compartment-mapper/package.json b/packages/compartment-mapper/package.json index 198c2c2e56..fef2b45a1c 100644 --- a/packages/compartment-mapper/package.json +++ b/packages/compartment-mapper/package.json @@ -57,7 +57,7 @@ "lint:eslint": "eslint .", "lint:types": "tsc", "prettier-fixtures": "prettier --write --with-node-modules './test/fixtures-*/**/*.*js'", - "test": "ava" + "test": "ses-ava" }, "dependencies": { "@endo/cjs-module-analyzer": "workspace:^", @@ -72,6 +72,7 @@ "@endo/evasive-transform": "workspace:^", "@endo/eventual-send": "workspace:^", "@endo/init": "workspace:^", + "@endo/ses-ava": "workspace:^", "ava": "catalog:dev", "babel-eslint": "^10.1.0", "c8": "catalog:dev", @@ -117,10 +118,14 @@ }, "ava": { "files": [ - "test/**/*.test.*" + "test/**/*.test.*", + "!test/**/*.node-condition.test.*" ], "timeout": "2m" }, + "sesAvaConfigs": { + "node-condition": "test/_ava-node-condition.config.js" + }, "typeCoverage": { "atLeast": 86.14 } diff --git a/packages/compartment-mapper/src/infer-exports.js b/packages/compartment-mapper/src/infer-exports.js index 4bbf612c19..e956ed5ba0 100644 --- a/packages/compartment-mapper/src/infer-exports.js +++ b/packages/compartment-mapper/src/infer-exports.js @@ -10,12 +10,14 @@ /** * @import {LanguageForExtension, PackageDescriptor} from './types.js' - * @import {Node} from './types/node-modules.js' + * @import {LogFn} from './types/external.js' + * @import {Exports, Imports, Node} from './types/node-modules.js' + * @import {PatternDescriptor} from './types/pattern-replacement.js' */ -import { join, relativize } from './node-module-specifier.js'; +import { relativize } from './node-module-specifier.js'; -const { entries, fromEntries, assign } = Object; +const { entries, fromEntries } = Object; const { isArray } = Array; /** @@ -60,15 +62,20 @@ function* interpretBrowserField(name, browser, main = 'index.js') { /** * @param {string} name - the name of the referrer package. - * @param {object} exports - the `exports` field from a package.json. + * @param {Exports} exports - the `exports` field from a package.json. * @param {Set} conditions - build conditions about the target environment * for selecting relevant exports, e.g., "browser" or "node". * @param {LanguageForExtension} types - an object to populate * with any recognized module's type, if implied by a tag. - * @yields {[string, string]} - * @returns {Generator<[string, string]>} + * @yields {[string, string | null]} + * @returns {Generator<[string, string | null]>} */ function* interpretExports(name, exports, conditions, types) { + // Null targets are exclusions (Node.js semantics). + if (exports === null) { + yield [name, null]; + return; + } if (isArray(exports)) { for (const section of exports) { const results = [...interpretExports(name, section, conditions, types)]; @@ -94,11 +101,7 @@ function* interpretExports(name, exports, conditions, types) { // eslint-disable-next-line no-continue continue; // or no-op } else if (key.startsWith('./') || key === '.') { - if (name === '.') { - yield* interpretExports(key, value, conditions, types); - } else { - yield* interpretExports(join(name, key), value, conditions, types); - } + yield* interpretExports(key, value, conditions, types); } else if (conditions.has(key)) { if (types && key === 'import' && typeof value === 'string') { // In this one case, the key "import" has carried a hint that the @@ -115,6 +118,54 @@ function* interpretExports(name, exports, conditions, types) { } } +/** + * Interprets the `imports` field from a package.json file. + * The imports field provides self-referencing subpath patterns that + * can be used to create private internal mappings. + * + * @param {Imports} imports - the `imports` field from a package.json. + * @param {Set} conditions - build conditions about the target environment + * @param {LogFn} log + * @yields {[string, string | null]} + * @returns {Generator<[string, string | null]>} + */ +function* interpretImports(imports, conditions, log) { + if (Object(imports) !== imports || Array.isArray(imports)) { + throw Error( + `Cannot interpret package.json imports property, must be object, got ${imports}`, + ); + } + for (const [key, value] of entries(imports)) { + // imports keys must start with '#' + if (!key.startsWith('#')) { + log(`Ignoring invalid imports key "${key}": must start with "#"`); + // eslint-disable-next-line no-continue + continue; + } + if (value === null) { + // Null targets are exclusions (Node.js semantics). + yield [key, null]; + } else if (typeof value === 'string') { + yield [key, relativize(value)]; + } else if (Object(value) === value && !isArray(value)) { + // Handle conditional imports + for (const [condition, target] of entries(value)) { + if (conditions.has(condition)) { + if (target === null) { + yield [key, null]; + } else if (typeof target === 'string') { + yield [key, relativize(target)]; + } + // Take only the first matching condition + break; + } + } + } else { + log(`Ignoring unsupported imports value for "${key}": ${typeof value}`); + } + } +} + /** * Given an unpacked `package.json`, generate a series of `[name, target]` * pairs to represent what this package exports. `name` is what the @@ -130,7 +181,7 @@ function* interpretExports(name, exports, conditions, types) { * for selecting relevant exports, e.g., "browser" or "node". * @param {LanguageForExtension} types - an object to populate * with any recognized module's type, if implied by a tag. - * @yields {[string, string]} + * @yields {[string, string | null]} */ export const inferExportsEntries = function* inferExportsEntries( { main, module, exports }, @@ -177,27 +228,115 @@ export const inferExports = (descriptor, conditions, types) => fromEntries(inferExportsEntries(descriptor, conditions, types)); /** + * Determines if a key or value contains a wildcard pattern. + * + * @param {string} key + * @param {string | null} value + * @returns {boolean} + */ +const hasWildcard = (key, value) => + key.includes('*') || (value?.includes('*') ?? false); + +/** + * Returns the number of `*` characters in a string. + * + * @param {string} str + * @returns {number} + */ +const countWildcards = str => (str.match(/\*/g) || []).length; + +/** + * Validates a wildcard pattern entry and logs warnings for invalid patterns. + * Returns true if the pattern is valid and should be used. + * + * @param {string} key + * @param {string} value + * @param {LogFn} log + * @returns {boolean} + */ +const validateWildcardPattern = (key, value, log) => { + const keyCount = countWildcards(key); + const valueCount = countWildcards(value); + if (keyCount > 1 || valueCount > 1) { + log(`Ignoring pattern with multiple wildcards "${key}": "${value}"`); + return false; + } + if (keyCount !== valueCount) { + log( + `Ignoring pattern with mismatched wildcard count "${key}" (${keyCount}) vs "${value}" (${valueCount})`, + ); + return false; + } + return true; +}; + +/** + * Infers exports, internal aliases, and wildcard patterns from a package descriptor. + * Extracts wildcard patterns from the `exports` and `imports` fields. * * @param {PackageDescriptor} descriptor * @param {Node['externalAliases']} externalAliases * @param {Node['internalAliases']} internalAliases + * @param {PatternDescriptor[]} patterns - array to populate with wildcard patterns * @param {Set} conditions * @param {Record} types + * @param {LogFn} log */ -export const inferExportsAndAliases = ( +export const inferExportsAliasesAndPatterns = ( descriptor, externalAliases, internalAliases, + patterns, conditions, types, + log, ) => { - const { name, type, main, module, exports, browser } = descriptor; + const { name, type, main, module, exports, imports, browser } = descriptor; + + // Process exports field - separate wildcards from concrete exports. + for (const [key, value] of inferExportsEntries( + descriptor, + conditions, + types, + )) { + if (value === null) { + // Null targets are exclusions. + // Only wildcard null targets need to be stored as patterns; + // concrete null targets are excluded by omission from aliases. + if (key.includes('*')) { + patterns.push({ from: key, to: null }); + } + // eslint-disable-next-line no-continue + continue; + } + if (hasWildcard(key, value)) { + if (validateWildcardPattern(key, value, log)) { + patterns.push({ from: key, to: value }); + } + } else { + externalAliases[key] = value; + } + } - // collect externalAliases from exports and main/module - assign( - externalAliases, - fromEntries(inferExportsEntries(descriptor, conditions, types)), - ); + // Process imports field (package self-referencing). + if (imports !== undefined) { + for (const [key, value] of interpretImports(imports, conditions, log)) { + if (value === null) { + if (key.includes('*')) { + patterns.push({ from: key, to: null }); + } + // eslint-disable-next-line no-continue + continue; + } + if (hasWildcard(key, value)) { + if (validateWildcardPattern(key, value, log)) { + patterns.push({ from: key, to: value }); + } + } else { + internalAliases[key] = value; + } + } + } // expose default module as package root // may be overwritten by browser field diff --git a/packages/compartment-mapper/src/link.js b/packages/compartment-mapper/src/link.js index d29f9ce9c3..c8c1882882 100644 --- a/packages/compartment-mapper/src/link.js +++ b/packages/compartment-mapper/src/link.js @@ -30,6 +30,7 @@ * FileCompartmentDescriptor, * FileModuleConfiguration, * } from './types.js' + * @import {SubpathReplacer} from './types/pattern-replacement.js' */ import { makeMapParsers } from './map-parser.js'; @@ -44,6 +45,7 @@ import { isCompartmentModuleConfiguration, isExitModuleConfiguration, } from './guards.js'; +import { makeMultiSubpathReplacer } from './pattern-replacement.js'; const { assign, create, entries, freeze } = Object; const { hasOwnProperty } = Object.prototype; @@ -100,7 +102,7 @@ const trimModuleSpecifierPrefix = (moduleSpecifier, prefix) => { * * @param {FileCompartmentDescriptor|PackageCompartmentDescriptor} compartmentDescriptor * @param {Record} compartments - * @param {string} compartmentName + * @param {FileUrlString} compartmentName * @param {Record} moduleDescriptors * @param {Record>} scopeDescriptors * @returns {ModuleMapHook | undefined} @@ -112,6 +114,14 @@ const makeModuleMapHook = ( moduleDescriptors, scopeDescriptors, ) => { + // Build pattern matcher once per compartment if patterns exist. + const { patterns } = /** @type {Partial} */ ( + compartmentDescriptor + ); + /** @type {SubpathReplacer | null} */ + const matchPattern = + patterns && patterns.length > 0 ? makeMultiSubpathReplacer(patterns) : null; + /** * @type {ModuleMapHook} */ @@ -161,6 +171,55 @@ const makeModuleMapHook = ( } } + // Check patterns for wildcard matches (before scopes). + // Patterns may resolve within the same compartment (internal patterns) + // or to a foreign compartment (dependency export patterns). + if (matchPattern) { + const match = matchPattern(moduleSpecifier); + if (match !== null) { + const { result: resolvedPath, compartment: foreignCompartmentName } = + match; + + // Null result means the specifier is explicitly excluded. + if (resolvedPath === null) { + throw Error( + `Cannot find module ${q(moduleSpecifier)} — excluded by null target pattern in ${q(compartmentName)}`, + ); + } + const targetCompartmentName = + /** @type {FileUrlString} */ + (foreignCompartmentName || compartmentName); + + // Write back to moduleDescriptors for caching, archival, and + // policy enforcement. The write-back must precede the policy + // check because enforcePolicyByModule verifies the specifier + // exists in compartmentDescriptor.modules (the same object). + moduleDescriptors[moduleSpecifier] = { + retained: true, + compartment: targetCompartmentName, + module: resolvedPath, + __createdBy: 'link-pattern', + }; + + // Policy enforcement for pattern-matched modules + enforcePolicyByModule(moduleSpecifier, compartmentDescriptor, { + exit: false, + errorHint: `Pattern matched in compartment ${q(compartmentName)}: module specifier ${q(moduleSpecifier)} mapped to ${q(resolvedPath)}`, + }); + + const targetCompartment = compartments[targetCompartmentName]; + if (targetCompartment === undefined) { + throw Error( + `Cannot import module specifier ${q(moduleSpecifier)} from missing compartment ${q(targetCompartmentName)}`, + ); + } + return { + compartment: targetCompartment, + namespace: resolvedPath, + }; + } + } + // Search for a scope that shares a prefix with the requested module // specifier. // This might be better with a trie, but only a benchmark on real-world @@ -298,7 +357,7 @@ export const link = ( }); const compartmentDescriptorEntries = - /** @type {[string, PackageCompartmentDescriptor|FileCompartmentDescriptor][]} */ ( + /** @type {[FileUrlString, PackageCompartmentDescriptor|FileCompartmentDescriptor][]} */ ( entries(compartmentDescriptors) ); for (const [ diff --git a/packages/compartment-mapper/src/node-modules.js b/packages/compartment-mapper/src/node-modules.js index ba3c2b5b7d..840dea9f5f 100644 --- a/packages/compartment-mapper/src/node-modules.js +++ b/packages/compartment-mapper/src/node-modules.js @@ -14,7 +14,7 @@ /* eslint no-shadow: 0 */ -import { inferExportsAndAliases } from './infer-exports.js'; +import { inferExportsAliasesAndPatterns } from './infer-exports.js'; import { parseLocatedJson } from './json.js'; import { join } from './node-module-specifier.js'; import { @@ -546,13 +546,17 @@ const graphPackage = async ( const externalAliases = {}; /** @type {Node['internalAliases']} */ const internalAliases = {}; + /** @type {Node['patterns']} */ + const patterns = []; - inferExportsAndAliases( + inferExportsAliasesAndPatterns( packageDescriptor, externalAliases, internalAliases, + patterns, conditions, types, + log, ); const parsers = inferParsers( @@ -571,6 +575,7 @@ const graphPackage = async ( explicitExports: exportsDescriptor !== undefined, externalAliases, internalAliases, + patterns, dependencyLocations, types, parsers, @@ -920,6 +925,7 @@ const translateGraph = ( label, sourceDirname, internalAliases, + patterns, parsers, types, packageDescriptor, @@ -948,7 +954,11 @@ const translateGraph = ( * @param {PackageCompartmentDescriptorName} packageLocation */ const digestExternalAliases = (dependencyName, packageLocation) => { - const { externalAliases, explicitExports } = graph[packageLocation]; + const { + externalAliases, + explicitExports, + patterns: dependencyPatterns, + } = graph[packageLocation]; for (const exportPath of keys(externalAliases).sort()) { const targetPath = externalAliases[exportPath]; // dependency name may be different from package's name, @@ -961,6 +971,24 @@ const translateGraph = ( module: targetPath, }; } + // Propagate export patterns from dependencies. + // Each dependency pattern like "./features/*.js" -> "./src/features/*.js" + // becomes "dep/features/*.js" -> "./src/features/*.js" on the dependee, + // resolving within the dependency's compartment. + if (dependencyPatterns) { + for (const { from, to } of dependencyPatterns) { + // Only propagate export patterns (starting with "./"), not + // import patterns (starting with "#") which are internal. + if (from.startsWith('./') || from === '.') { + const externalFrom = join(dependencyName, from); + patterns.push({ + from: externalFrom, + to, + compartment: packageLocation, + }); + } + } + } // if the exports field is not present, then all modules must be accessible if (!explicitExports) { scopes[dependencyName] = { @@ -997,6 +1025,7 @@ const translateGraph = ( sourceDirname, modules: moduleDescriptors, scopes, + ...(patterns.length > 0 ? { patterns } : {}), parsers, types, policy: /** @type {SomePackagePolicy} */ (packagePolicy), diff --git a/packages/compartment-mapper/src/pattern-replacement.js b/packages/compartment-mapper/src/pattern-replacement.js new file mode 100644 index 0000000000..e41455f31a --- /dev/null +++ b/packages/compartment-mapper/src/pattern-replacement.js @@ -0,0 +1,198 @@ +/** + * Provides pattern matching for Node.js-style subpath exports and imports. + * Patterns use `*` as a wildcard that matches any string, including across + * `/` path separators, matching Node.js semantics. + * + * @module + */ + +/** + * @import { + * SubpathReplacer, + * SubpathReplacerResult, + * SubpathMapping, + * PatternDescriptor, + * ResolvedPattern, + * } from './types/pattern-replacement.js' + */ + +const { entries } = Object; +const { isArray } = Array; + +/** + * Validates that the pattern and replacement have the same number of wildcards. + * Node.js restricts subpath patterns to exactly one `*` on each side. + * + * @param {string} pattern - Source pattern + * @param {string} replacement - Target pattern + * @throws {Error} If wildcard counts don't match + */ +export const assertMatchingWildcardCount = (pattern, replacement) => { + const patternCount = (pattern.match(/\*/g) || []).length; + const replacementCount = (replacement.match(/\*/g) || []).length; + if (patternCount !== replacementCount) { + throw new Error( + `Wildcard count mismatch: "${pattern}" has ${patternCount}, "${replacement}" has ${replacementCount}`, + ); + } +}; + +/** + * Compare two pattern keys using Node.js's PATTERN_KEY_COMPARE ordering. + * This prefers the longest prefix before `*`, then the longest full key. + * For example, `./foo/*.js` outranks `./foo/*`. + * + * @param {string} a + * @param {string} b + * @returns {number} + */ +const patternKeyCompare = (a, b) => { + const aBaseLength = a.indexOf('*') + 1; + const bBaseLength = b.indexOf('*') + 1; + if (aBaseLength > bBaseLength) { + return -1; + } + if (bBaseLength > aBaseLength) { + return 1; + } + if (a.length > b.length) { + return -1; + } + if (b.length > a.length) { + return 1; + } + return 0; +}; + +/** + * Classifies a pattern/replacement pair as exact or wildcard and adds it + * to the appropriate collection. + * + * @param {string} pattern + * @param {string | null} replacement + * @param {string | undefined} compartment + * @param {Map} exactEntries + * @param {ResolvedPattern[]} wildcardEntries + */ +const classifyPatternEntry = ( + pattern, + replacement, + compartment, + exactEntries, + wildcardEntries, +) => { + if (replacement !== null) { + assertMatchingWildcardCount(pattern, replacement); + } + + const wildcardIndex = pattern.indexOf('*'); + if (wildcardIndex === -1) { + exactEntries.set(pattern, { replacement, compartment }); + return; + } + + const prefix = pattern.slice(0, wildcardIndex); + const suffix = pattern.slice(wildcardIndex + 1); + let replacementPrefix = null; + let replacementSuffix = null; + if (replacement !== null) { + const replacementWildcardIndex = replacement.indexOf('*'); + replacementPrefix = replacement.slice(0, replacementWildcardIndex); + replacementSuffix = replacement.slice(replacementWildcardIndex + 1); + } + wildcardEntries.push({ + pattern, + prefix, + suffix, + replacementPrefix, + replacementSuffix, + compartment, + }); +}; + +/** + * Creates a multi-pattern replacer for Node.js-style subpath patterns. + * + * Patterns are matched by specificity: the pattern with the longest matching + * prefix before the `*` wins. Exact entries (no `*`) take precedence over + * all wildcard patterns. + * + * The `*` wildcard matches any substring, including substrings that contain + * `/`, matching Node.js semantics. + * + * @param {PatternDescriptor[] | SubpathMapping} mapping - Pattern to replacement mapping + * @returns {SubpathReplacer} Function that matches a specifier and returns the replacement + */ +export const makeMultiSubpathReplacer = mapping => { + /** @type {Map} */ + const exactEntries = new Map(); + /** @type {ResolvedPattern[]} */ + const wildcardEntries = []; + + /** @type {Array<[string, string | null, string | undefined]>} */ + let normalizedEntries; + if (isArray(mapping)) { + normalizedEntries = /** @type {typeof normalizedEntries} */ ( + mapping.map(entry => { + if (isArray(entry)) { + // [pattern, replacement] tuple + return [entry[0], entry[1], undefined]; + } + // PatternDescriptor { from, to, compartment? } + return [entry.from, entry.to, entry.compartment]; + }) + ); + } else { + normalizedEntries = /** @type {typeof normalizedEntries} */ ( + entries(mapping).map(([pattern, replacement]) => [ + pattern, + replacement, + undefined, + ]) + ); + } + + for (const [pattern, replacement, compartment] of normalizedEntries) { + classifyPatternEntry( + pattern, + replacement, + compartment, + exactEntries, + wildcardEntries, + ); + } + + // Match Node.js PATTERN_KEY_COMPARE semantics for subpath pattern + // precedence: longest prefix before `*`, then longest full pattern key. + wildcardEntries.sort((a, b) => patternKeyCompare(a.pattern, b.pattern)); + + return specifier => { + // Exact entries take precedence + const exact = exactEntries.get(specifier); + if (exact) { + return { result: exact.replacement, compartment: exact.compartment }; + } + + // Try wildcard patterns in specificity order + for (const entry of wildcardEntries) { + if ( + specifier.startsWith(entry.prefix) && + specifier.endsWith(entry.suffix) && + specifier.length >= entry.prefix.length + entry.suffix.length + ) { + // Null replacement means this path is explicitly excluded. + if (entry.replacementPrefix === null) { + return { result: null, compartment: entry.compartment }; + } + const captured = specifier.slice( + entry.prefix.length, + specifier.length - entry.suffix.length, + ); + const result = `${entry.replacementPrefix}${captured}${entry.replacementSuffix}`; + return { result, compartment: entry.compartment }; + } + } + + return null; + }; +}; diff --git a/packages/compartment-mapper/src/types/compartment-map-schema.ts b/packages/compartment-mapper/src/types/compartment-map-schema.ts index 632c0ab0bd..0cc4b32392 100644 --- a/packages/compartment-mapper/src/types/compartment-map-schema.ts +++ b/packages/compartment-mapper/src/types/compartment-map-schema.ts @@ -15,6 +15,7 @@ import type { import type { CanonicalName } from './canonical-name.js'; import type { FileUrlString } from './external.js'; import type { SomePackagePolicy } from './policy-schema.js'; +import type { PatternDescriptor } from './pattern-replacement.js'; import type { LiteralUnion } from './typescript.js'; /** @@ -105,6 +106,13 @@ export interface PackageCompartmentDescriptor scopes: Record>; + /** + * Wildcard patterns for dynamic module resolution within this compartment. + * `*` matches any substring including `/` (Node.js semantics). + * Stripped during digest/archiving - expanded patterns become concrete module entries. + */ + patterns?: Array; + sourceDirname: string; } @@ -182,6 +190,7 @@ export type ModuleConfiguration = export type ModuleConfigurationCreator = | 'link' + | 'link-pattern' | 'transform' | 'import-hook' | 'digest' diff --git a/packages/compartment-mapper/src/types/node-modules.ts b/packages/compartment-mapper/src/types/node-modules.ts index 318e04b3ea..33f3fa6cf2 100644 --- a/packages/compartment-mapper/src/types/node-modules.ts +++ b/packages/compartment-mapper/src/types/node-modules.ts @@ -13,9 +13,50 @@ import type { LogOptions, PackageDependenciesHook, } from './external.js'; +import type { PatternDescriptor } from './pattern-replacement.js'; import type { LiteralUnion } from './typescript.js'; import { ATTENUATORS_COMPARTMENT } from '../policy-format.js'; +/** + * A mapping of conditions to their resolved exports. + * Each condition key (e.g., "import", "require", "node", "default") + * maps to an {@link Exports} value. + * + * @see {@link https://github.com/sindresorhus/type-fest/blob/850b33c4dd292e0ff8cff039ee167d69be324fce/source/package-json.d.ts#L227-L248 | type-fest ExportConditions} + */ +export type ExportConditions = { + // eslint-disable-next-line no-use-before-define + [condition: string]: Exports; +}; + +/** + * Entry points of a module, optionally with conditions and subpath exports. + * Follows the recursive structure defined by Node.js for `package.json` + * `exports` and `imports` fields. + * + * - `null` excludes a subpath (null target). + * - `string` is a direct path. + * - `Array` is a fallback list (first match wins). + * - `ExportConditions` is a mapping of conditions to nested `Exports`. + * + * @see {@link https://github.com/sindresorhus/type-fest/blob/850b33c4dd292e0ff8cff039ee167d69be324fce/source/package-json.d.ts#L227-L248 | type-fest Exports} + */ +export type Exports = + | null + | string + | Array + | ExportConditions; + +/** + * The `imports` field of `package.json`. + * Keys must start with `#`. + * + * @see {@link https://github.com/sindresorhus/type-fest/blob/850b33c4dd292e0ff8cff039ee167d69be324fce/source/package-json.d.ts#L227-L248 | type-fest Imports} + */ +export type Imports = { + [key: `#${string}`]: Exports; +}; + export type CommonDependencyDescriptors = Record< string, { spec: string; alias: string } @@ -79,10 +120,8 @@ export interface PackageDescriptor { */ name: string; version?: string; - /** - * TODO: Update with proper type when this field is handled. - */ - exports?: unknown; + exports?: Exports; + imports?: Imports; type?: 'module' | 'commonjs'; dependencies?: Record; devDependencies?: Record; @@ -120,6 +159,11 @@ export interface Node { explicitExports: boolean; internalAliases: Record; externalAliases: Record; + /** + * Wildcard patterns extracted from the `exports` and `imports` fields. + * `*` matches exactly one path segment (Node.js semantics). + */ + patterns: PatternDescriptor[]; /** * The name of the original package's parent directory, for reconstructing * a sourceURL that is likely to converge with the original location in an IDE. diff --git a/packages/compartment-mapper/src/types/pattern-replacement.ts b/packages/compartment-mapper/src/types/pattern-replacement.ts new file mode 100644 index 0000000000..2d8d570a0e --- /dev/null +++ b/packages/compartment-mapper/src/types/pattern-replacement.ts @@ -0,0 +1,70 @@ +/** + * Types for subpath pattern matching. + * + * @module + */ + +/** + * Result of a successful pattern match. + */ +export interface SubpathReplacerResult { + /** The resolved module path, or null for null-target exclusions */ + result: string | null; + /** Optional compartment name for cross-compartment patterns */ + compartment?: string; +} + +/** + * A function that attempts to match a specifier against patterns + * and returns the replacement path (with optional compartment), or null if no match. + */ +export type SubpathReplacer = ( + specifier: string, +) => SubpathReplacerResult | null; + +/** + * Input format for pattern mappings - either an array of tuples, + * an array of PatternDescriptors, or a record object. + */ +export type SubpathMapping = + | Array<[pattern: string, replacement: string]> + | Record; + +/** + * Internal representation of a parsed pattern entry, split into + * prefix/suffix for efficient matching. + */ +export interface ResolvedPattern { + /** The original pattern key */ + pattern: string; + /** The part of the pattern before `*` */ + prefix: string; + /** The part of the pattern after `*` */ + suffix: string; + /** The part of the replacement before `*`, or null for exclusions */ + replacementPrefix: string | null; + /** The part of the replacement after `*`, or null for exclusions */ + replacementSuffix: string | null; + /** Optional compartment for cross-compartment patterns */ + compartment?: string; +} + +/** + * A pattern descriptor for wildcard-based module resolution. + * The `from` pattern is matched against module specifiers, + * and `to` is the replacement pattern. + * + * Wildcards (`*`) match any substring including `/` (Node.js semantics). + */ +export interface PatternDescriptor { + /** Source pattern with wildcard, e.g., "./lib/*.js" */ + from: string; + /** Target pattern with wildcard, e.g., "./*.js". Null means exclusion. */ + to: string | null; + /** + * Optional compartment name where the resolved module lives. + * When absent, the pattern resolves within the owning compartment. + * Set when propagating export patterns from a dependency package. + */ + compartment?: string; +} diff --git a/packages/compartment-mapper/test/_ava-node-condition.config.js b/packages/compartment-mapper/test/_ava-node-condition.config.js new file mode 100644 index 0000000000..7ab9b971fc --- /dev/null +++ b/packages/compartment-mapper/test/_ava-node-condition.config.js @@ -0,0 +1,5 @@ +export default { + files: ['test/**/*.node-condition.test.*'], + nodeArguments: ['-C', 'blue-moon'], + timeout: '2m', +}; diff --git a/packages/compartment-mapper/test/_subpath-patterns-assertions.js b/packages/compartment-mapper/test/_subpath-patterns-assertions.js new file mode 100644 index 0000000000..f101ddf52b --- /dev/null +++ b/packages/compartment-mapper/test/_subpath-patterns-assertions.js @@ -0,0 +1,89 @@ +/** + * Shared assertion logic for subpath pattern tests. + * + * Both the Node.js parity tests and the Compartment Mapper (Endo) tests + * import from this module so that the expected values are defined in exactly + * one place. If both test suites pass, parity is verified by construction. + * + * @module + */ + +/** @import {ExecutionContext} from 'ava' */ + +export const expectedMain = { + alpha: 'alpha', + betaGamma: 'beta-gamma', + exact: 'exact-match', + helper: 'helper', + specificity: 'specific', +}; + +export const expectedConditionalBlue = { + widget: 'blue-widget', +}; + +export const expectedConditionalDefault = { + widget: 'default-widget', +}; + +export const expectedPrecedence = { + tieBreak: 'suffix-specific', +}; + +export const expectedImportsEdgeCasesDefault = { + helper: 'helper', + cond: 'cond-default', +}; + +export const expectedImportsEdgeCasesDev = { + helper: 'helper', + cond: 'cond-dev', +}; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertMain = (t, namespace) => { + t.like(namespace, expectedMain); +}; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertConditionalBlue = (t, namespace) => { + t.like(namespace, expectedConditionalBlue); +}; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertConditionalDefault = (t, namespace) => { + t.like(namespace, expectedConditionalDefault); +}; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertPrecedence = (t, namespace) => { + t.like(namespace, expectedPrecedence); +}; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertImportsEdgeCasesDefault = (t, namespace) => { + t.like(namespace, expectedImportsEdgeCasesDefault); +}; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertImportsEdgeCasesDev = (t, namespace) => { + t.like(namespace, expectedImportsEdgeCasesDev); +}; diff --git a/packages/compartment-mapper/test/export-patterns.test.js b/packages/compartment-mapper/test/export-patterns.test.js new file mode 100644 index 0000000000..a356328349 --- /dev/null +++ b/packages/compartment-mapper/test/export-patterns.test.js @@ -0,0 +1,31 @@ +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; + +const fixture = new URL( + 'fixtures-export-patterns/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + t.like(namespace, { + value: 'foobar', + helper: 'utility', + }); +}; + +scaffold( + 'export-patterns', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); diff --git a/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/lib/util.js b/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/lib/util.js new file mode 100644 index 0000000000..83c082bd26 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/lib/util.js @@ -0,0 +1,2 @@ +// Module matching imports pattern #internal/util -> ./lib/util.js +export const helper = 'utility'; diff --git a/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/main.js new file mode 100644 index 0000000000..cb0561db59 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/main.js @@ -0,0 +1,6 @@ +// Entry point that imports via wildcard patterns +// * matches "foo/y/bar/z" across / separators (Node.js semantics) +import { value } from 'app/x/foo/y/bar/z.js'; +import { helper } from '#internal/util.js'; + +export { value, helper }; diff --git a/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/package.json new file mode 100644 index 0000000000..c8bbf8b14b --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/package.json @@ -0,0 +1,12 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./main.js", + "./x/*.js": "./src/x/*.js" + }, + "imports": { + "#internal/*.js": "./lib/*.js" + } +} diff --git a/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/src/x/foo/y/bar/z.js b/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/src/x/foo/y/bar/z.js new file mode 100644 index 0000000000..07746a9f16 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-export-patterns/node_modules/app/src/x/foo/y/bar/z.js @@ -0,0 +1,2 @@ +// Module matching pattern ./x/foo/y/bar/z -> ./src/x/foo/y/bar/z.js +export const value = 'foobar'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js new file mode 100644 index 0000000000..2607ae0347 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js @@ -0,0 +1,7 @@ +// This import resolves through "absolute-pattern-lib" which has an +// exports pattern that maps "./smuggle/*.js" to "/etc/*.js". +// The compartment-mapper must reject this because the resolved module +// specifier is an absolute path, not a relative one. +import { secret } from 'absolute-pattern-lib/smuggle/passwd.js'; + +export { secret }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-app/package.json new file mode 100644 index 0000000000..2580d10e0d --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "absolute-pattern-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "absolute-pattern-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-lib/package.json new file mode 100644 index 0000000000..8e9c51c3a4 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-lib/package.json @@ -0,0 +1,12 @@ +{ + "name": "absolute-pattern-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/main.js", + "./smuggle/*.js": "/etc/*.js" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-lib/src/main.js new file mode 100644 index 0000000000..9ec589f902 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/absolute-pattern-lib/src/main.js @@ -0,0 +1 @@ +export const main = 'absolute-pattern-lib-main'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/conditional-import.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/conditional-import.js new file mode 100644 index 0000000000..eb75b16078 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/conditional-import.js @@ -0,0 +1,6 @@ +// Conditional pattern: "./things/*.js" -> { "blue-moon": "./src/blue/*.js", "default": "./src/default/*.js" } +// Under "blue-moon" condition, resolves to ./src/blue/widget.js +// Under default conditions, resolves to ./src/default/widget.js +import { widget } from 'cond-patterns-lib/things/widget.js'; + +export { widget }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/globstar-import.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/globstar-import.js new file mode 100644 index 0000000000..180bf42bdf --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/globstar-import.js @@ -0,0 +1,5 @@ +// Attempts to import through a globstar pattern. +// Node.js does not resolve this — globstar patterns are silently ignored. +import { val } from 'globstar-lib/deep/nested/thing.js'; + +export { val }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/main.js new file mode 100644 index 0000000000..595dc496a0 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/main.js @@ -0,0 +1,20 @@ +// Single-segment pattern match: ./features/*.js -> ./src/features/*.js +import { alpha } from 'patterns-lib/features/alpha.js'; + +// Cross-separator pattern match: * matches "beta/gamma" spanning "/" +import { betaGamma } from 'patterns-lib/features/beta/gamma.js'; + +// Exact entry takes precedence over pattern. +// If the pattern were used instead, this would resolve to gamma.js (value 'beta-gamma'). +// The exact export points to exact-target.js (value 'exact-match'), proving precedence. +import { exact } from 'patterns-lib/features/beta/exact'; + +// Imports pattern: #internal/*.js -> ./src/internal/*.js +import { helper } from 'patterns-lib'; + +// Pattern specificity: ./utils/private/*.js (longer prefix) wins over ./utils/*.js. +// If specificity were ignored, this could resolve to ./src/broad/private/thing.js ('broad'). +// The specific pattern resolves to ./src/private/thing.js ('specific'). +import { specificity } from 'patterns-lib/utils/private/thing.js'; + +export { alpha, betaGamma, exact, helper, specificity }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/multi-star-import.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/multi-star-import.js new file mode 100644 index 0000000000..20fdd2a847 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/multi-star-import.js @@ -0,0 +1,5 @@ +// Attempts to import through a multi-star pattern. +// Node.js does not resolve this — multi-star patterns are silently ignored. +import { value } from 'multi-star-lib/x/foo/y/bar/z.js'; + +export { value }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/null-target-import.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/null-target-import.js new file mode 100644 index 0000000000..358c78096f --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/null-target-import.js @@ -0,0 +1,6 @@ +// This import should fail because patterns-lib exports +// "./features/secret/*.js": null, which excludes this path +// even though "./features/*.js" would otherwise match. +import { secret } from 'patterns-lib/features/secret/data.js'; + +export { secret }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/package.json new file mode 100644 index 0000000000..2e3897afe4 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/package.json @@ -0,0 +1,14 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "cond-patterns-lib": "^1.0.0", + "patterns-lib": "^1.0.0", + "multi-star-lib": "^1.0.0", + "globstar-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/precedence-import.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/precedence-import.js new file mode 100644 index 0000000000..9b97ae096b --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/app/precedence-import.js @@ -0,0 +1,5 @@ +// Node's pattern key comparison prefers "./tie/*.js" over "./tie/*" +// because both share the same prefix length and the longer full key wins. +import { tieBreak } from 'patterns-lib/tie/bar.js'; + +export { tieBreak }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-app/main.js new file mode 100644 index 0000000000..ffeecbcf62 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-app/main.js @@ -0,0 +1,3 @@ +import { value } from 'array-imports-lib'; + +export { value }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-app/package.json new file mode 100644 index 0000000000..60a9ceaf0b --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "array-imports-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "array-imports-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-lib/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-lib/main.js new file mode 100644 index 0000000000..5149346ede --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-lib/main.js @@ -0,0 +1 @@ +export const value = 'should not reach here'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-lib/package.json new file mode 100644 index 0000000000..1e8f20b1b6 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/array-imports-lib/package.json @@ -0,0 +1,12 @@ +{ + "name": "array-imports-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./main.js" + }, + "imports": ["#internal"], + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-app/main.js new file mode 100644 index 0000000000..3652efd835 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-app/main.js @@ -0,0 +1,3 @@ +import { value } from 'bad-browser-lib'; + +export { value }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-app/package.json new file mode 100644 index 0000000000..37c12a5c88 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "bad-browser-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "bad-browser-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-lib/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-lib/main.js new file mode 100644 index 0000000000..5149346ede --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-lib/main.js @@ -0,0 +1 @@ +export const value = 'should not reach here'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-lib/package.json new file mode 100644 index 0000000000..5aadec18c4 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-browser-lib/package.json @@ -0,0 +1,12 @@ +{ + "name": "bad-browser-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./main.js" + }, + "browser": 42, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-app/main.js new file mode 100644 index 0000000000..979d4cbc2e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-app/main.js @@ -0,0 +1,3 @@ +import { value } from 'bad-exports-lib'; + +export { value }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-app/package.json new file mode 100644 index 0000000000..aa4ef2b96b --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "bad-exports-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "bad-exports-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-lib/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-lib/main.js new file mode 100644 index 0000000000..5149346ede --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-lib/main.js @@ -0,0 +1 @@ +export const value = 'should not reach here'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-lib/package.json new file mode 100644 index 0000000000..006bc75d80 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/bad-exports-lib/package.json @@ -0,0 +1,9 @@ +{ + "name": "bad-exports-lib", + "version": "1.0.0", + "type": "module", + "exports": 42, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-app/main.js new file mode 100644 index 0000000000..1b66b677ca --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-app/main.js @@ -0,0 +1,3 @@ +import lib from 'browser-cjs-lib'; + +export const { env } = lib; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-app/package.json new file mode 100644 index 0000000000..f04444261b --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "browser-cjs-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "browser-cjs-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/package.json new file mode 100644 index 0000000000..4ec0c7e940 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/package.json @@ -0,0 +1,14 @@ +{ + "name": "browser-cjs-lib", + "version": "1.0.0", + "main": "./src/main.js", + "browser": { + "./src/main.js": "./src/browser-main.js", + "./src/node-only.js": "./src/browser-replacement.js", + "external-dep": "./src/external-shim.js", + "ignored-dep": false + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/browser-main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/browser-main.js new file mode 100644 index 0000000000..a1c527264c --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/browser-main.js @@ -0,0 +1 @@ +exports.env = 'browser'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/browser-replacement.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/browser-replacement.js new file mode 100644 index 0000000000..6493de5567 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/browser-replacement.js @@ -0,0 +1 @@ +exports.source = 'browser-replacement'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/external-shim.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/external-shim.js new file mode 100644 index 0000000000..63dd7f7452 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/external-shim.js @@ -0,0 +1 @@ +exports.shimmed = true; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/main.js new file mode 100644 index 0000000000..a9973c0e22 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/main.js @@ -0,0 +1 @@ +exports.env = 'node'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/node-only.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/node-only.js new file mode 100644 index 0000000000..9d3c39ac5c --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-cjs-lib/src/node-only.js @@ -0,0 +1 @@ +exports.source = 'node-only'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-app/main.js new file mode 100644 index 0000000000..5e7f37050a --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-app/main.js @@ -0,0 +1,3 @@ +import lib from 'browser-string-lib'; + +export const { env } = lib; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-app/package.json new file mode 100644 index 0000000000..4afb75b458 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "browser-string-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "browser-string-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/package.json new file mode 100644 index 0000000000..1c47013236 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/package.json @@ -0,0 +1,9 @@ +{ + "name": "browser-string-lib", + "version": "1.0.0", + "main": "./src/main.js", + "browser": "./src/browser-main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/src/browser-main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/src/browser-main.js new file mode 100644 index 0000000000..13f40b8987 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/src/browser-main.js @@ -0,0 +1 @@ +exports.env = 'browser-string'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/src/main.js new file mode 100644 index 0000000000..a9973c0e22 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/browser-string-lib/src/main.js @@ -0,0 +1 @@ +exports.env = 'node'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/package.json new file mode 100644 index 0000000000..3f957774e7 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/package.json @@ -0,0 +1,15 @@ +{ + "name": "cond-patterns-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/main.js", + "./things/*.js": { + "blue-moon": "./src/blue/*.js", + "default": "./src/default/*.js" + } + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/blue/widget.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/blue/widget.js new file mode 100644 index 0000000000..e8197fc40b --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/blue/widget.js @@ -0,0 +1 @@ +export const widget = 'blue-widget'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/default/widget.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/default/widget.js new file mode 100644 index 0000000000..4880878c0f --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/default/widget.js @@ -0,0 +1 @@ +export const widget = 'default-widget'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/main.js new file mode 100644 index 0000000000..0d7b33e0ed --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/cond-patterns-lib/src/main.js @@ -0,0 +1 @@ +export const main = 'main'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js new file mode 100644 index 0000000000..56535ddedb --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js @@ -0,0 +1,4 @@ +import { main } from 'exports-edge-cases-lib'; +import { nested } from 'exports-edge-cases-lib/nested'; + +export { main, nested }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-app/package.json new file mode 100644 index 0000000000..698cd1a220 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "exports-edge-cases-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "exports-edge-cases-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/package.json new file mode 100644 index 0000000000..14bcbd4596 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/package.json @@ -0,0 +1,15 @@ +{ + "name": "exports-edge-cases-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/main.js", + "./": "./src/slash-target.js", + "./nested": { + "import": "./src/nested-esm.js" + } + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/deep.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/deep.js new file mode 100644 index 0000000000..1ee0a058ea --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/deep.js @@ -0,0 +1 @@ +export const deep = 'deep'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/main.js new file mode 100644 index 0000000000..c505613655 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/main.js @@ -0,0 +1 @@ +export const main = 'exports-edge-cases-main'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/nested-esm.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/nested-esm.js new file mode 100644 index 0000000000..db1c2b5688 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/nested-esm.js @@ -0,0 +1 @@ +export const nested = 'nested-esm'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/slash-target.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/slash-target.js new file mode 100644 index 0000000000..04cca019e6 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/exports-edge-cases-lib/src/slash-target.js @@ -0,0 +1 @@ +export const slash = 'slash-target'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/package.json new file mode 100644 index 0000000000..48c08e23ad --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/package.json @@ -0,0 +1,12 @@ +{ + "name": "globstar-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/main.js", + "./**/*.js": "./src/**/*.js" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/src/deep/nested/thing.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/src/deep/nested/thing.js new file mode 100644 index 0000000000..0c17373e67 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/src/deep/nested/thing.js @@ -0,0 +1 @@ +export const val = 'should-not-resolve'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/src/main.js new file mode 100644 index 0000000000..0d7b33e0ed --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/globstar-lib/src/main.js @@ -0,0 +1 @@ +export const main = 'main'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js new file mode 100644 index 0000000000..b5834a33ea --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js @@ -0,0 +1,3 @@ +import { helper, cond } from 'imports-edge-cases-lib'; + +export { helper, cond }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-app/package.json new file mode 100644 index 0000000000..134b65da74 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "imports-edge-cases-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "imports-edge-cases-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/package.json new file mode 100644 index 0000000000..076aa3fc3c --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/package.json @@ -0,0 +1,23 @@ +{ + "name": "imports-edge-cases-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/main.js", + "./mismatched-export/*": "./src/mismatched-export" + }, + "imports": { + "#helper": "./src/helper.js", + "#excluded": null, + "#secret/*.js": null, + "#cond": { "development": "./src/cond-dev.js", "default": "./src/cond-default.js" }, + "#cond-null": { "development": null }, + "#mismatched/*": "./src/mismatched", + "invalid-key": "./src/invalid.js", + "#bad-value": 42, + "#cond-unreachable": { "never-set-condition": "./src/unreachable.js" } + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/cond-default.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/cond-default.js new file mode 100644 index 0000000000..d3ca69916d --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/cond-default.js @@ -0,0 +1 @@ +export const cond = 'cond-default'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/cond-dev.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/cond-dev.js new file mode 100644 index 0000000000..0203a9bc34 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/cond-dev.js @@ -0,0 +1 @@ +export const cond = 'cond-dev'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/helper.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/helper.js new file mode 100644 index 0000000000..276ec41453 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/helper.js @@ -0,0 +1 @@ +export const helper = 'helper'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/main.js new file mode 100644 index 0000000000..7c1a650609 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/imports-edge-cases-lib/src/main.js @@ -0,0 +1,2 @@ +export { helper } from '#helper'; +export { cond } from '#cond'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-app/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-app/main.js new file mode 100644 index 0000000000..bb1d274e15 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-app/main.js @@ -0,0 +1,3 @@ +import { entry } from 'module-field-lib'; + +export { entry }; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-app/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-app/package.json new file mode 100644 index 0000000000..dab833ffa2 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-app/package.json @@ -0,0 +1,11 @@ +{ + "name": "module-field-app", + "version": "1.0.0", + "type": "module", + "dependencies": { + "module-field-lib": "^1.0.0" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/package.json new file mode 100644 index 0000000000..2396691ea5 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/package.json @@ -0,0 +1,9 @@ +{ + "name": "module-field-lib", + "version": "1.0.0", + "main": "./src/main-cjs.js", + "module": "./src/main-esm.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/src/main-cjs.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/src/main-cjs.js new file mode 100644 index 0000000000..36eb421241 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/src/main-cjs.js @@ -0,0 +1 @@ +export const entry = 'cjs'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/src/main-esm.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/src/main-esm.js new file mode 100644 index 0000000000..e411a94256 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/module-field-lib/src/main-esm.js @@ -0,0 +1 @@ +export const entry = 'esm'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/package.json new file mode 100644 index 0000000000..b28667956e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/package.json @@ -0,0 +1,12 @@ +{ + "name": "multi-star-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/main.js", + "./x/*/y/*/z.js": "./src/x/*/y/*/z.js" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/src/main.js new file mode 100644 index 0000000000..0d7b33e0ed --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/src/main.js @@ -0,0 +1 @@ +export const main = 'main'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/src/x/foo/y/bar/z.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/src/x/foo/y/bar/z.js new file mode 100644 index 0000000000..db839c33be --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/multi-star-lib/src/x/foo/y/bar/z.js @@ -0,0 +1 @@ +export const value = 'should-not-resolve'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/package.json b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/package.json new file mode 100644 index 0000000000..0675f4c063 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/package.json @@ -0,0 +1,21 @@ +{ + "name": "patterns-lib", + "version": "1.0.0", + "type": "module", + "exports": { + ".": "./src/main.js", + "./features/*.js": "./src/features/*.js", + "./features/beta/exact": "./src/features/beta/exact-target.js", + "./features/secret/*.js": null, + "./tie/*": "./src/tie/broad/*.js", + "./tie/*.js": "./src/tie/*.js", + "./utils/*.js": "./src/broad/*.js", + "./utils/private/*.js": "./src/private/*.js" + }, + "imports": { + "#internal/*.js": "./src/internal/*.js" + }, + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/alpha.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/alpha.js new file mode 100644 index 0000000000..c7cd6c83b7 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/alpha.js @@ -0,0 +1 @@ +export const from = 'broad'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/features/alpha.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/features/alpha.js new file mode 100644 index 0000000000..f92982de98 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/features/alpha.js @@ -0,0 +1 @@ +export const alpha = "broad"; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/private/thing.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/private/thing.js new file mode 100644 index 0000000000..06d0b08c78 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/broad/private/thing.js @@ -0,0 +1 @@ +export const specificity = 'broad'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/alpha.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/alpha.js new file mode 100644 index 0000000000..17562cd507 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/alpha.js @@ -0,0 +1 @@ +export const alpha = 'alpha'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/beta/exact-target.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/beta/exact-target.js new file mode 100644 index 0000000000..3f49524e03 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/beta/exact-target.js @@ -0,0 +1 @@ +export const exact = 'exact-match'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/beta/gamma.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/beta/gamma.js new file mode 100644 index 0000000000..b19e3aba0f --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/beta/gamma.js @@ -0,0 +1 @@ +export const betaGamma = 'beta-gamma'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/secret/data.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/secret/data.js new file mode 100644 index 0000000000..881c75dc3c --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/features/secret/data.js @@ -0,0 +1,3 @@ +// This file exists on disk but should NOT be accessible via import +// because the null-target export pattern excludes it. +export const secret = 'should-not-be-reachable'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/internal/helper.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/internal/helper.js new file mode 100644 index 0000000000..276ec41453 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/internal/helper.js @@ -0,0 +1 @@ +export const helper = 'helper'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/main.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/main.js new file mode 100644 index 0000000000..d63f5ba9b4 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/main.js @@ -0,0 +1 @@ +export { helper } from '#internal/helper.js'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/private/thing.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/private/thing.js new file mode 100644 index 0000000000..0ec4f727dd --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/private/thing.js @@ -0,0 +1 @@ +export const specificity = 'specific'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/tie/bar.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/tie/bar.js new file mode 100644 index 0000000000..edc49f51ff --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/tie/bar.js @@ -0,0 +1 @@ +export const tieBreak = 'suffix-specific'; diff --git a/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/tie/broad/bar.js.js b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/tie/broad/bar.js.js new file mode 100644 index 0000000000..07ed983628 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-package-imports-exports/node_modules/patterns-lib/src/tie/broad/bar.js.js @@ -0,0 +1 @@ +export const tieBreak = 'broad'; diff --git a/packages/compartment-mapper/test/pattern-replacement.test.js b/packages/compartment-mapper/test/pattern-replacement.test.js new file mode 100644 index 0000000000..528eb72885 --- /dev/null +++ b/packages/compartment-mapper/test/pattern-replacement.test.js @@ -0,0 +1,195 @@ +import test from 'ava'; +import { + makeMultiSubpathReplacer, + assertMatchingWildcardCount, +} from '../src/pattern-replacement.js'; + +// assertMatchingWildcardCount tests + +test('assertMatchingWildcardCount - passes for matching counts', t => { + t.notThrows(() => assertMatchingWildcardCount('./foo', './bar')); + t.notThrows(() => assertMatchingWildcardCount('./*.js', './*.js')); +}); + +test('assertMatchingWildcardCount - throws for mismatched counts', t => { + t.throws(() => assertMatchingWildcardCount('./*', './a'), { + message: /wildcard count mismatch.*\bhas 1\b.*\bhas 0\b/i, + }); +}); + +// makeMultiSubpathReplacer tests + +test('exact match (no wildcards)', t => { + const replace = makeMultiSubpathReplacer({ './foo': './bar' }); + t.deepEqual(replace('./foo'), { result: './bar', compartment: undefined }); + t.is(replace('./baz'), null); +}); + +test('single wildcard matches one segment', t => { + const replace = makeMultiSubpathReplacer({ + './features/*.js': './src/features/*.js', + }); + t.deepEqual(replace('./features/alpha.js'), { + result: './src/features/alpha.js', + compartment: undefined, + }); +}); + +test('wildcard matches across / separators (Node.js semantics)', t => { + const replace = makeMultiSubpathReplacer({ + './features/*.js': './src/features/*.js', + }); + // * matches "beta/gamma" which contains "/" + t.deepEqual(replace('./features/beta/gamma.js'), { + result: './src/features/beta/gamma.js', + compartment: undefined, + }); +}); + +test('wildcard does not match empty string when prefix+suffix fill the specifier', t => { + const replace = makeMultiSubpathReplacer({ + './features/*.js': './src/features/*.js', + }); + // "./features/.js" has * matching empty string — length check allows this + t.deepEqual(replace('./features/.js'), { + result: './src/features/.js', + compartment: undefined, + }); +}); + +test('accepts array of tuples', t => { + const replace = makeMultiSubpathReplacer([ + ['./first/*', './a/*'], + ['./second/*', './b/*'], + ]); + t.deepEqual(replace('./first/x'), { + result: './a/x', + compartment: undefined, + }); + t.deepEqual(replace('./second/y'), { + result: './b/y', + compartment: undefined, + }); +}); + +test('accepts PatternDescriptor array', t => { + const replace = makeMultiSubpathReplacer([ + { from: './features/*.js', to: './src/*.js', compartment: 'dep-pkg' }, + ]); + t.deepEqual(replace('./features/alpha.js'), { + result: './src/alpha.js', + compartment: 'dep-pkg', + }); +}); + +test('exact entry takes precedence over wildcard', t => { + const replace = makeMultiSubpathReplacer([ + ['./features/beta/exact', './exact-target'], + ['./features/*.js', './src/features/*.js'], + ]); + t.deepEqual(replace('./features/beta/exact'), { + result: './exact-target', + compartment: undefined, + }); + t.deepEqual(replace('./features/alpha.js'), { + result: './src/features/alpha.js', + compartment: undefined, + }); +}); + +test('longer prefix wins (specificity)', t => { + const replace = makeMultiSubpathReplacer([ + ['./*.js', './fallback/*.js'], + ['./features/*.js', './src/features/*.js'], + ]); + // "./features/*.js" has longer prefix "./features/" than "./" + t.deepEqual(replace('./features/alpha.js'), { + result: './src/features/alpha.js', + compartment: undefined, + }); + t.deepEqual(replace('./other.js'), { + result: './fallback/other.js', + compartment: undefined, + }); +}); + +test('Node-style tie-break prefers longer full pattern key', t => { + const replace = makeMultiSubpathReplacer([ + ['./foo/*', './src/foo/*.js'], + ['./foo/*.js', './src/*.js'], + ]); + t.deepEqual(replace('./foo/bar.js'), { + result: './src/bar.js', + compartment: undefined, + }); +}); + +test('wildcard count mismatch throws', t => { + t.throws(() => makeMultiSubpathReplacer({ './*': './a' }), { + message: /wildcard count mismatch/i, + }); +}); + +test('imports-style patterns with #', t => { + const replace = makeMultiSubpathReplacer({ + '#internal/*.js': './lib/*.js', + }); + t.deepEqual(replace('#internal/util.js'), { + result: './lib/util.js', + compartment: undefined, + }); + // * matches across / per Node.js semantics + t.deepEqual(replace('#internal/deep/path.js'), { + result: './lib/deep/path.js', + compartment: undefined, + }); +}); + +test('returns null for empty mapping', t => { + const replace = makeMultiSubpathReplacer({}); + t.is(replace('./anything'), null); +}); + +test('null target excludes exact entry', t => { + const replace = makeMultiSubpathReplacer([ + { from: './private', to: null }, + { from: './*', to: './src/*' }, + ]); + t.deepEqual(replace('./private'), { + result: null, + compartment: undefined, + }); + // Other paths still resolve normally + t.deepEqual(replace('./other'), { + result: './src/other', + compartment: undefined, + }); +}); + +test('null target excludes wildcard pattern', t => { + const replace = makeMultiSubpathReplacer([ + { from: './features/*.js', to: './src/features/*.js' }, + { from: './features/private/*.js', to: null }, + ]); + // Longer prefix wins — excluded + t.deepEqual(replace('./features/private/thing.js'), { + result: null, + compartment: undefined, + }); + // Non-excluded path resolves normally + t.deepEqual(replace('./features/alpha.js'), { + result: './src/features/alpha.js', + compartment: undefined, + }); +}); + +test('handles root pattern', t => { + const replace = makeMultiSubpathReplacer({ + '.': './index.js', + }); + t.deepEqual(replace('.'), { + result: './index.js', + compartment: undefined, + }); + t.is(replace('./other'), null); +}); diff --git a/packages/compartment-mapper/test/subpath-patterns-node-condition.node-condition.test.js b/packages/compartment-mapper/test/subpath-patterns-node-condition.node-condition.test.js new file mode 100644 index 0000000000..aa52eaf9d3 --- /dev/null +++ b/packages/compartment-mapper/test/subpath-patterns-node-condition.node-condition.test.js @@ -0,0 +1,22 @@ +/** + * Node.js parity test for conditional subpath patterns with a user-specified + * condition. + * + * This test runs under --conditions=blue-moon (configured via ses-ava in + * test/_ava-node-condition.config.js). It verifies that Node.js selects the + * "blue-moon" branch of a conditional pattern, confirming parity with the + * Compartment Mapper test in subpath-patterns.test.js. + */ +import test from 'ava'; +import { assertConditionalBlue } from './_subpath-patterns-assertions.js'; + +const fixtureBase = new URL( + 'fixtures-package-imports-exports/node_modules/app/', + import.meta.url, +); + +test('conditional pattern selects user-specified condition in Node.js', async t => { + // With --conditions=blue-moon, "blue-moon" is selected over "default". + const ns = await import(new URL('conditional-import.js', fixtureBase).href); + assertConditionalBlue(t, ns); +}); diff --git a/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js b/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js new file mode 100644 index 0000000000..fbc03d3990 --- /dev/null +++ b/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js @@ -0,0 +1,172 @@ +/** + * Node.js parity test for subpath pattern replacement. + * + * This test runs the fixtures under plain Node.js to verify they are valid + * Node.js packages. The same expected values are asserted in the Compartment + * Mapper test (subpath-patterns.test.js), so parity is verified by + * construction: if both tests pass, the behaviors are equivalent. + */ +import test from 'ava'; +import { + assertMain, + assertConditionalDefault, + assertPrecedence, + assertImportsEdgeCasesDefault, +} from './_subpath-patterns-assertions.js'; + +const fixtureBase = new URL( + 'fixtures-package-imports-exports/node_modules/app/', + import.meta.url, +); + +test('subpath patterns - node parity', async t => { + const ns = await import(new URL('main.js', fixtureBase).href); + assertMain(t, ns); +}); + +test('null-target patterns are excluded by Node.js', async t => { + // The file exists on disk but the null-target export prevents resolution. + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/app/null-target-import.js', + import.meta.url, + ).href + ), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); + +test('conditional patterns - default condition in Node.js', async t => { + // Without --conditions=blue-moon, "default" is selected. + const ns = await import(new URL('conditional-import.js', fixtureBase).href); + assertConditionalDefault(t, ns); +}); + +test('multi-star patterns are not resolved by Node.js', async t => { + // Node.js restricts subpath patterns to exactly one `*` per side. + // Entries with multiple `*` are silently ignored (never match). + // This test will fail if Node.js begins to support multi-star patterns, + // signaling that we should revisit our implementation. + const fixtureDir = new URL( + 'fixtures-package-imports-exports/node_modules/', + import.meta.url, + ); + // The main export (no wildcards) should still work. + const main = await import( + new URL('multi-star-lib/src/main.js', fixtureDir).href + ); + t.is(main.main, 'main'); + + // The multi-star subpath pattern should NOT resolve. + await t.throwsAsync( + () => import(new URL('app/multi-star-import.js', fixtureDir).href), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); + +test('imports edge cases - node parity', async t => { + // Non-wildcard alias (#helper) and conditional import (#cond under default + // conditions) resolve correctly under Node.js. + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js', + import.meta.url, + ).href + ); + assertImportsEdgeCasesDefault(t, ns); +}); + +test('array imports field is silently ignored by Node.js', async t => { + // Node.js silently ignores an invalid array `imports` field and resolves + // the package via `exports` instead. The compartment-mapper is stricter + // and throws. This test documents the Node.js behavior. + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/array-imports-app/main.js', + import.meta.url, + ).href + ); + t.is(ns.value, 'should not reach here'); +}); + +test('exports edge cases - node parity', async t => { + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js', + import.meta.url, + ).href + ); + t.is(ns.main, 'exports-edge-cases-main'); + t.is(ns.nested, 'nested-esm'); +}); + +test('non-object exports field is rejected by Node.js', async t => { + // Node.js rejects the invalid numeric exports field. The error code varies + // by version: ERR_PACKAGE_PATH_NOT_EXPORTED on 18/20, ERR_MODULE_NOT_FOUND + // on 22+. We just verify it throws. + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/bad-exports-app/main.js', + import.meta.url, + ).href + ), + ); +}); + +test('globstar patterns are not resolved by Node.js', async t => { + // Node.js does not support globstar (**) in subpath patterns. + // Entries with ** are silently ignored (never match). + // This test will fail if Node.js begins to support globstar patterns, + // signaling that we should revisit our implementation. + const fixtureDir = new URL( + 'fixtures-package-imports-exports/node_modules/', + import.meta.url, + ); + // The main export (no wildcards) should still work. + const main = await import( + new URL('globstar-lib/src/main.js', fixtureDir).href + ); + t.is(main.main, 'main'); + + // The globstar subpath pattern should NOT resolve. + await t.throwsAsync( + () => import(new URL('app/globstar-import.js', fixtureDir).href), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); + +test('absolute path in subpath pattern is rejected by Node.js', async t => { + // A package whose exports map "./smuggle/*.js" to "/etc/*.js" should not + // allow importing absolute paths. Node.js rejects this because the + // resolved target does not start with "./". + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js', + import.meta.url, + ).href + ), + { + code: 'ERR_INVALID_PACKAGE_TARGET', + }, + ); +}); + +test('Node prefers the longer full pattern key on equal prefix length', async t => { + // This exercises Node's pattern key ordering with overlapping keys: + // "./tie/*" and "./tie/*.js". Node resolves "patterns-lib/tie/bar.js" + // through "./tie/*.js", not the broader "./tie/*" entry. + const ns = await import(new URL('precedence-import.js', fixtureBase).href); + assertPrecedence(t, ns); +}); diff --git a/packages/compartment-mapper/test/subpath-patterns.test.js b/packages/compartment-mapper/test/subpath-patterns.test.js new file mode 100644 index 0000000000..6fb5c760ae --- /dev/null +++ b/packages/compartment-mapper/test/subpath-patterns.test.js @@ -0,0 +1,242 @@ +/** + * Compartment Mapper test for subpath pattern replacement. + * + * Uses the scaffold harness to exercise the fixture through all execution + * paths (loadLocation, importLocation, makeArchive, parseArchive, etc.). + * + * The expected values match those asserted in node-parity-subpath-patterns.test.js, + * so if both tests pass, the Compartment Mapper has parity with Node.js for + * these cases. + */ +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { ZipReader } from '@endo/zip'; +import { scaffold, readPowers } from './scaffold.js'; +import { importLocation, makeArchive } from '../index.js'; +import { + assertMain, + assertConditionalBlue, + assertConditionalDefault, + assertPrecedence, + assertImportsEdgeCasesDev, +} from './_subpath-patterns-assertions.js'; + +const fixture = new URL( + 'fixtures-package-imports-exports/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertMain(t, namespace); +}; + +scaffold( + 'subpath-patterns', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); + +test('patterns are stripped from archived compartment-map.json', async t => { + const archive = await makeArchive(readPowers, fixture, { + modules: {}, + Compartment, + }); + const reader = new ZipReader(archive); + const compartmentMapBytes = reader.files.get('compartment-map.json'); + t.truthy(compartmentMapBytes, 'archive contains compartment-map.json'); + const compartmentMap = JSON.parse( + new TextDecoder().decode(compartmentMapBytes.content), + ); + for (const [name, descriptor] of Object.entries( + compartmentMap.compartments, + )) { + t.is( + /** @type {any} */ (descriptor).patterns, + undefined, + `compartment ${name} should not have patterns in archive`, + ); + } +}); + +test('conditional pattern resolves under user-specified condition', async t => { + const conditionalFixture = new URL( + 'fixtures-package-imports-exports/node_modules/app/conditional-import.js', + import.meta.url, + ).toString(); + const { namespace } = await importLocation(readPowers, conditionalFixture, { + conditions: new Set(['blue-moon']), + }); + assertConditionalBlue(t, namespace); +}); + +test('conditional pattern falls back to default without user condition', async t => { + const conditionalFixture = new URL( + 'fixtures-package-imports-exports/node_modules/app/conditional-import.js', + import.meta.url, + ).toString(); + const { namespace } = await importLocation(readPowers, conditionalFixture); + assertConditionalDefault(t, namespace); +}); + +test('policy allows pattern-matched imports when package is permitted', async t => { + const policy = { + entry: { packages: { 'patterns-lib': true } }, + resources: { 'patterns-lib': {} }, + }; + const { namespace } = await importLocation(readPowers, fixture, { policy }); + assertMain(t, namespace); +}); + +test('policy rejects pattern-matched imports when package is not permitted', async t => { + const policy = { + entry: { packages: {} }, + resources: {}, + }; + await t.throwsAsync(() => importLocation(readPowers, fixture, { policy })); +}); + +test('array imports field in package.json causes an exception', async t => { + const arrayImportsFixture = new URL( + 'fixtures-package-imports-exports/node_modules/array-imports-app/main.js', + import.meta.url, + ).toString(); + await t.throwsAsync(() => importLocation(readPowers, arrayImportsFixture), { + message: /Cannot interpret package.json imports property, must be object/, + }); +}); + +test('imports edge cases: non-wildcard alias, conditional, null, invalid key, bad value, mismatched wildcard', async t => { + const edgeCasesFixture = new URL( + 'fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js', + import.meta.url, + ).toString(); + const { namespace } = await importLocation(readPowers, edgeCasesFixture, { + conditions: new Set(['development']), + }); + assertImportsEdgeCasesDev(t, namespace); + // The following are exercised by graph construction but do not produce runtime exports: + // - "invalid-key" (no # prefix): logged and skipped + // - "#excluded": null (non-wildcard null target): skipped + // - "#secret/*.js": null (wildcard null target): stored as pattern + // - "#bad-value": 42 (unsupported value): logged and skipped + // - "#mismatched/*" / "./mismatched-export/*": mismatched wildcard count +}); + +test('browser field and commonjs default module', async t => { + const browserCjsFixture = new URL( + 'fixtures-package-imports-exports/node_modules/browser-cjs-app/main.js', + import.meta.url, + ).toString(); + // With the 'browser' condition, the browser field remaps ./src/main.js to + // ./src/browser-main.js, exercising lines 420-433 in inferExportsAliasesAndPatterns. + // The package has no exports/module fields and type != 'module', exercising + // the commonjs default module path (lines 414-415). + const { namespace } = await importLocation(readPowers, browserCjsFixture, { + conditions: new Set(['browser']), + }); + t.is(namespace.env, 'browser'); +}); + +test('browser field as string remaps main export', async t => { + const browserStringFixture = new URL( + 'fixtures-package-imports-exports/node_modules/browser-string-app/main.js', + import.meta.url, + ).toString(); + const { namespace } = await importLocation(readPowers, browserStringFixture, { + conditions: new Set(['browser']), + }); + t.is(namespace.env, 'browser-string'); +}); + +test('exports edge cases: ./ key skipped, nested subpath with name != "."', async t => { + const exportsEdgeCasesFixture = new URL( + 'fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js', + import.meta.url, + ).toString(); + const { namespace } = await importLocation( + readPowers, + exportsEdgeCasesFixture, + ); + t.is(namespace.main, 'exports-edge-cases-main'); + t.is(namespace.nested, 'nested-esm'); +}); + +test('non-object exports field causes an exception', async t => { + const badExportsFixture = new URL( + 'fixtures-package-imports-exports/node_modules/bad-exports-app/main.js', + import.meta.url, + ).toString(); + await t.throwsAsync(() => importLocation(readPowers, badExportsFixture), { + message: /Cannot interpret package.json exports property/, + }); +}); + +test('non-string non-object browser field causes an exception', async t => { + const badBrowserFixture = new URL( + 'fixtures-package-imports-exports/node_modules/bad-browser-app/main.js', + import.meta.url, + ).toString(); + await t.throwsAsync( + () => + importLocation(readPowers, badBrowserFixture, { + conditions: new Set(['browser']), + }), + { + message: /Cannot interpret package.json browser property/, + }, + ); +}); + +test('null-target pattern excludes matching specifier', async t => { + const nullTargetFixture = new URL( + 'fixtures-package-imports-exports/node_modules/app/null-target-import.js', + import.meta.url, + ).toString(); + await t.throwsAsync(() => importLocation(readPowers, nullTargetFixture), { + message: /excluded by null target pattern/, + }); +}); + +test('absolute path in subpath pattern replacement is rejected', async t => { + const absolutePatternFixture = new URL( + 'fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js', + import.meta.url, + ).toString(); + await t.throwsAsync( + () => importLocation(readPowers, absolutePatternFixture), + { + message: /Cannot find file for internal module/, + }, + ); +}); + +test('module field selects ESM entry point', async t => { + const moduleFieldFixture = new URL( + 'fixtures-package-imports-exports/node_modules/module-field-app/main.js', + import.meta.url, + ).toString(); + // The "module" field (without "exports") yields the ESM entry when the + // "import" condition is active, which is always the case in the + // compartment-mapper. + const { namespace } = await importLocation(readPowers, moduleFieldFixture); + t.is(namespace.entry, 'esm'); +}); + +test('pattern tie-break matches Node precedence rules', async t => { + const precedenceFixture = new URL( + 'fixtures-package-imports-exports/node_modules/app/precedence-import.js', + import.meta.url, + ).toString(); + const { namespace } = await importLocation(readPowers, precedenceFixture); + assertPrecedence(t, namespace); +}); diff --git a/yarn.lock b/yarn.lock index c0223d5bfd..61582cd848 100644 --- a/yarn.lock +++ b/yarn.lock @@ -594,6 +594,7 @@ __metadata: "@endo/init": "workspace:^" "@endo/module-source": "workspace:^" "@endo/path-compare": "workspace:^" + "@endo/ses-ava": "workspace:^" "@endo/trampoline": "workspace:^" "@endo/zip": "workspace:^" ava: "catalog:dev"