diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/entrypoint.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/entrypoint.js new file mode 100644 index 000000000..7d1413742 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/entrypoint.js @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-var-requires */ +// Requires a local package directory that exposes its entry point only through +// the package.json `exports` field (no `main`), so the target file is found +// solely via exports resolution. +const pkg = require('./local-pkg') + +module.exports = { pkg } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/browser.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/browser.js new file mode 100644 index 000000000..12942b921 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/browser.js @@ -0,0 +1 @@ +module.exports = { name: 'browser' } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/fallback.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/fallback.js new file mode 100644 index 000000000..20d6cf1dc --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/fallback.js @@ -0,0 +1 @@ +module.exports = { name: 'fallback' } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/main.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/main.js new file mode 100644 index 000000000..fc346dfed --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/lib/main.js @@ -0,0 +1 @@ +module.exports = { name: 'main' } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/package.json new file mode 100644 index 000000000..c849e677c --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/package-exports/local-pkg/package.json @@ -0,0 +1,11 @@ +{ + "name": "local-pkg", + "version": "1.0.0", + "exports": { + ".": { + "browser": "./lib/browser.js", + "node": "./lib/main.js", + "default": "./lib/fallback.js" + } + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/config.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/config.js new file mode 100644 index 000000000..cef61f5de --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/config.js @@ -0,0 +1 @@ +module.exports = { name: 'config' } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/entrypoint.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/entrypoint.js new file mode 100644 index 000000000..4c716bd45 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/entrypoint.js @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-var-requires */ +// Exercises Node.js subpath imports (the package.json `imports` field): +// - `#config` -> a relative file within the package +// - `#internal/bar` -> a wildcard-mapped relative file +// - `#dep` -> an external package (lodash) +const config = require('#config') +const bar = require('#internal/bar') +const dep = require('#dep') + +module.exports = { config, bar, dep } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/package.json b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/package.json new file mode 100644 index 000000000..c0a64631e --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/package.json @@ -0,0 +1,10 @@ +{ + "name": "subpath-imports-fixture", + "version": "1.0.0", + "main": "entrypoint.js", + "imports": { + "#config": "./config.js", + "#internal/*": "./src/internal/*.js", + "#dep": "lodash" + } +} diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/src/internal/bar.js b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/src/internal/bar.js new file mode 100644 index 000000000..6d1307221 --- /dev/null +++ b/packages/cli/src/services/check-parser/__tests__/check-parser-fixtures/subpath-imports/src/internal/bar.js @@ -0,0 +1 @@ +module.exports = { name: 'bar' } diff --git a/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts b/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts index 6f60e05be..693b0c20c 100644 --- a/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts +++ b/packages/cli/src/services/check-parser/__tests__/check-parser.spec.ts @@ -612,6 +612,50 @@ describe('dependency-parser - parser()', () => { // parsing must not throw a DependencyParseError. expect(dependencies.map(d => d.filePath)).not.toContain(toAbsolutePath('native.node')) }) + + it('should resolve package.json subpath #imports', async () => { + // Node.js subpath imports (the package.json `imports` field, referenced + // via `#`-prefixed specifiers) must be discovered and bundled + // automatically, without a manual `include`. + const toAbsolutePath = (...filepath: string[]) => fixt.abspath('subpath-imports', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + restricted: false, + }) + + const { dependencies } = await parser.parse(toAbsolutePath('entrypoint.js')) + const filePaths = dependencies.map(d => d.filePath) + + // Both a direct relative target (#config) and a wildcard-mapped target + // (#internal/bar) are bundled. + expect(filePaths).toContain(toAbsolutePath('config.js')) + expect(filePaths).toContain(toAbsolutePath('src', 'internal', 'bar.js')) + + // The raw `#`-specifiers must never leak into the dependency set, and the + // external target (#dep -> lodash) is recorded as external, not bundled. + expect(filePaths.some(filePath => filePath.includes('#'))).toBe(false) + expect(filePaths.some(filePath => filePath.includes('lodash'))).toBe(false) + }) + + it('should resolve a package.json exports field', async () => { + // The imported package exposes its entry point only through a conditional + // `exports` map (no `main`), so the target file is discovered solely via + // exports resolution. The `node` condition is selected: the earlier, + // non-matching `browser` condition is skipped and the `default` fallback + // is not reached. + const toAbsolutePath = (...filepath: string[]) => fixt.abspath('package-exports', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + restricted: false, + }) + + const { dependencies } = await parser.parse(toAbsolutePath('entrypoint.js')) + const filePaths = dependencies.map(d => d.filePath) + + expect(filePaths).toContain(toAbsolutePath('local-pkg', 'lib', 'main.js')) + expect(filePaths).not.toContain(toAbsolutePath('local-pkg', 'lib', 'browser.js')) + expect(filePaths).not.toContain(toAbsolutePath('local-pkg', 'lib', 'fallback.js')) + }) }) describe('restricted mode', () => { @@ -641,6 +685,40 @@ describe('dependency-parser - parser()', () => { ]) }) + it('should resolve relative subpath #imports', async () => { + // In restricted mode the nearest package.json is not bundled, so the + // `imports` map is unavailable at runtime (a documented limitation), but + // dependency resolution must still discover the mapped target files + // and must not crash. + const toAbsolutePath = (...filepath: string[]) => fixt.abspath('subpath-imports', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + restricted: true, + }) + + const { dependencies } = await parser.parse(toAbsolutePath('entrypoint.js')) + const filePaths = dependencies.map(d => d.filePath) + + expect(filePaths).toContain(toAbsolutePath('config.js')) + expect(filePaths).toContain(toAbsolutePath('src', 'internal', 'bar.js')) + expect(filePaths.some(filePath => filePath.includes('#'))).toBe(false) + }) + + it('should resolve a package.json exports field', async () => { + const toAbsolutePath = (...filepath: string[]) => fixt.abspath('package-exports', ...filepath) + const parser = new Parser({ + supportedNpmModules: defaultNpmModules, + restricted: true, + }) + + const { dependencies } = await parser.parse(toAbsolutePath('entrypoint.js')) + const filePaths = dependencies.map(d => d.filePath) + + expect(filePaths).toContain(toAbsolutePath('local-pkg', 'lib', 'main.js')) + expect(filePaths).not.toContain(toAbsolutePath('local-pkg', 'lib', 'browser.js')) + expect(filePaths).not.toContain(toAbsolutePath('local-pkg', 'lib', 'fallback.js')) + }) + it('should report a missing entrypoint file', async () => { const missingEntrypoint = fixt.abspath('does-not-exist.js') expect.assertions(1) diff --git a/packages/cli/src/services/check-parser/package-files/__tests__/package-json-file.spec.ts b/packages/cli/src/services/check-parser/package-files/__tests__/package-json-file.spec.ts index fd9a12ab3..cfa4b3610 100644 --- a/packages/cli/src/services/check-parser/package-files/__tests__/package-json-file.spec.ts +++ b/packages/cli/src/services/check-parser/package-files/__tests__/package-json-file.spec.ts @@ -404,4 +404,93 @@ describe('package.json file', () => { expect(paths[0].target.path).toBe('./index.js') }) }) + + describe('resolveImportPath', () => { + const importConditions = ['import', 'node', 'module-sync', 'default'] + const requireConditions = ['require', 'node', 'module-sync', 'default'] + + it('resolves a plain-string relative target', () => { + const testFile = PackageJsonFile.make('/pkg/package.json', { + name: 'foo', + version: '1.0.0', + imports: { + '#config': './config.js', + }, + }) + + const { paths } = testFile.resolveImportPath('#config', importConditions) + + expect(paths).toHaveLength(1) + expect(paths[0].target.path).toBe('./config.js') + }) + + it('resolves a conditional target differently for import and require', () => { + const testFile = PackageJsonFile.make('/pkg/package.json', { + name: 'foo', + version: '1.0.0', + imports: { + '#dep': { + import: './dep.mjs', + require: './dep.cjs', + default: './dep.js', + }, + } as any, + }) + + const importResult = testFile.resolveImportPath('#dep', importConditions) + expect(importResult.paths).toHaveLength(1) + expect(importResult.paths[0].target.path).toBe('./dep.mjs') + + const requireResult = testFile.resolveImportPath('#dep', requireConditions) + expect(requireResult.paths).toHaveLength(1) + expect(requireResult.paths[0].target.path).toBe('./dep.cjs') + }) + + it('resolves a wildcard target', () => { + const testFile = PackageJsonFile.make('/pkg/package.json', { + name: 'foo', + version: '1.0.0', + imports: { + '#internal/*': './src/internal/*.js', + }, + }) + + const { paths } = testFile.resolveImportPath('#internal/foo', importConditions) + + expect(paths).toHaveLength(1) + expect(paths[0].target.path).toBe('./src/internal/foo.js') + }) + + it('returns a bare/external target verbatim', () => { + // Import maps may point at an external package rather than a file within + // the declaring package; the target is returned unchanged so the resolver + // can treat it as an external (or workspace-neighbor) specifier. + const testFile = PackageJsonFile.make('/pkg/package.json', { + name: 'foo', + version: '1.0.0', + imports: { + '#dep': 'lodash', + }, + }) + + const { paths } = testFile.resolveImportPath('#dep', importConditions) + + expect(paths).toHaveLength(1) + expect(paths[0].target.path).toBe('lodash') + }) + + it('returns no paths for an unmatched specifier', () => { + const testFile = PackageJsonFile.make('/pkg/package.json', { + name: 'foo', + version: '1.0.0', + imports: { + '#config': './config.js', + }, + }) + + const { paths } = testFile.resolveImportPath('#missing', importConditions) + + expect(paths).toHaveLength(0) + }) + }) }) diff --git a/packages/cli/src/services/check-parser/package-files/package-json-file.ts b/packages/cli/src/services/check-parser/package-files/package-json-file.ts index da74c9b8d..eb1afb12a 100644 --- a/packages/cli/src/services/check-parser/package-files/package-json-file.ts +++ b/packages/cli/src/services/check-parser/package-files/package-json-file.ts @@ -82,10 +82,14 @@ export class PackageJsonFile { return !!this.jsonFile.data.exports } + hasImports (): boolean { + return !!this.jsonFile.data.imports + } + resolveExportPath (exportPath: string, conditions: ConditionKey[]): ResolveResult { const resolver = PathResolver.createFromPaths( this.basePath, - this.#resolveExports(this.jsonFile.data.exports ?? {}, conditions), + this.#resolveConditionalTargets(this.jsonFile.data.exports ?? {}, conditions), ) // Normalize the export path to the canonical subpath form used by @@ -107,16 +111,44 @@ export class PackageJsonFile { return resolver.resolve(exportPath) } - #resolveExports (exports: Exports, conditions: ConditionKey[]): Record { - if (typeof exports === 'string') { + /** + * Resolves a Node.js subpath import specifier (a `#`-prefixed specifier such + * as `#utils/foo`) against the package's `imports` map. + * + * Unlike `resolveExportPath`, no subpath normalization is needed: `imports` + * keys and the specifier are both `#`-prefixed and matched verbatim. Relative + * targets in the result resolve against `basePath`; bare targets are external + * package (or workspace neighbor) specifiers. + * + * See https://nodejs.org/api/packages.html#subpath-imports + */ + resolveImportPath (importPath: string, conditions: ConditionKey[]): ResolveResult { + const resolver = PathResolver.createFromPaths( + this.basePath, + this.#resolveConditionalTargets(this.jsonFile.data.imports ?? {}, conditions), + ) + + return resolver.resolve(importPath) + } + + // Resolves the conditional targets of an `exports` or `imports` map into a + // flat `subpath -> [target]` mapping. Both fields share the same target shape + // (string | null | array | nested conditions), so the same logic applies. + #resolveConditionalTargets ( + map: Exports, + conditions: ConditionKey[], + ): Record { + // A plain-string `exports` shorthand maps the root subpath to that target. + // (`imports` is never a plain string, but sharing the branch is harmless.) + if (typeof map === 'string') { return { - '.': [exports], + '.': [map], } } const resolved: Record = {} - for (const [from, target] of Object.entries(exports)) { + for (const [from, target] of Object.entries(map)) { const resolvedTarget = this.#resolveExportTarget(target, conditions) if (resolvedTarget !== undefined) { resolved[from] = [resolvedTarget] diff --git a/packages/cli/src/services/check-parser/package-files/resolver.ts b/packages/cli/src/services/check-parser/package-files/resolver.ts index d6bccd055..f5c3f2520 100644 --- a/packages/cli/src/services/check-parser/package-files/resolver.ts +++ b/packages/cli/src/services/check-parser/package-files/resolver.ts @@ -288,6 +288,12 @@ type RelativePathLocalDependency = { sourceFile: SourceFile } +type ImportsPathLocalDependency = { + kind: 'imports-path' + importPath: string + sourceFile: SourceFile +} + type WorkspaceNeighborLocalDependency = { kind: 'workspace-neighbor' neighbor: Package @@ -307,6 +313,7 @@ type LocalDependency = | SupportingTSConfigResolvedPathLocalDependency | SupportingTSConfigBaseUrlRelativePathLocalDependency | RelativePathLocalDependency + | ImportsPathLocalDependency | WorkspaceNeighborLocalDependency type MissingDependency = { @@ -631,6 +638,47 @@ export class PackageFilesResolver { const context = LookupContext.forFilePath(filePath) + // Resolves a bare/external specifier: prefer a workspace-neighbor package + // when one provides the files, otherwise record it as an external + // dependency. Shared by ordinary specifiers and by bare targets produced + // by a package.json `imports` mapping. + const resolveWorkspaceOrExternal = async (specifier: string, source: RawDependencySource) => { + if (this.workspace) { + const { name, path: exportPath } = splitExternalPath(specifier) + const neighbor = this.workspace.memberByName(name) + if (neighbor) { + const sourceFile = await this.cache.sourceFile(neighbor.path, context) + if (sourceFile !== undefined) { + const resolvedFiles = await this.resolveSourceFile(sourceFile, context, { + exportPath, + source, + }) + let found = false + for (const resolvedFile of resolvedFiles) { + debug('Found workspace neighbor file %s', resolvedFile.meta.filePath) + resolved.local.push({ + kind: 'workspace-neighbor', + neighbor, + importPath: specifier, + sourceFile: resolvedFile, + }) + found = true + } + if (found) { + debug('Found workspace neighbor reference %s', neighbor.path) + usedNeighbors.add(neighbor) + return + } + } + } + } + + debug(`Found external dependency %s`, specifier) + resolved.external.push({ + importPath: specifier, + }) + } + resolve: for (const { importPath, source } of dependencies) { if (isBuiltinPath(importPath)) { @@ -765,49 +813,53 @@ export class PackageFilesResolver { } } + // Node.js subpath imports (`#`-prefixed specifiers) resolve against the + // `imports` map of the nearest package.json (the importing file's package + // scope). Every `#` specifier terminates here — an unmatched one is left + // unbundled rather than being misrecorded as an external npm dependency. if (isImportsPath(importPath)) { - debug('Found local imports path %s', - importPath, - ) - - // TODO - continue resolve - } - - if (this.workspace) { - const { name, path: exportPath } = splitExternalPath(importPath) - const neighbor = this.workspace.memberByName(name) - if (neighbor) { - const sourceFile = await this.cache.sourceFile(neighbor.path, context) - if (sourceFile !== undefined) { - const resolvedFiles = await this.resolveSourceFile(sourceFile, context, { - exportPath, - source, - }) - let found = false - for (const resolvedFile of resolvedFiles) { - debug('Found workspace neighbor file %s', resolvedFile.meta.filePath) - resolved.local.push({ - kind: 'workspace-neighbor', - neighbor, - importPath, - sourceFile: resolvedFile, - }) - found = true - } - if (found) { - debug('Found workspace neighbor reference %s', neighbor.path) - usedNeighbors.add(neighbor) - continue resolve + if (packageJson?.hasImports()) { + const { root, paths } = packageJson.resolveImportPath(importPath, [ + source, + 'node', + 'module-sync', + 'default', + ]) + + for (const { target } of paths) { + if (isLocalPath(target.path)) { + // Relative targets point at files within the declaring package, + // resolved relative to the package root (not the importing file). + const relativeDepPath = path.resolve(root, target.path) + const sourceFile = await this.cache.sourceFile(relativeDepPath, context) + if (sourceFile !== undefined) { + const resolvedFiles = await this.resolveSourceFile(sourceFile, context) + for (const resolvedFile of resolvedFiles) { + debug('Found subpath imports file %s', resolvedFile.meta.filePath) + resolved.local.push({ + kind: 'imports-path', + importPath, + sourceFile: resolvedFile, + }) + } + } else { + debug('Failed to find subpath imports file %s', relativeDepPath) + resolved.missing.push({ + importPath, + filePath: relativeDepPath, + }) + } + } else { + // Bare targets are external packages (or workspace neighbors). + await resolveWorkspaceOrExternal(target.path, source) } } } + + continue resolve } - debug(`Found external dependency %s`, importPath) - resolved.external.push({ - importPath, - }) + await resolveWorkspaceOrExternal(importPath, source) } const requiredNeighbors = new Set()