diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/node_modules/@monorepo/workspaceA b/packages/knip/fixtures/workspaces-complex-module-resolution/node_modules/@monorepo/workspaceA new file mode 120000 index 000000000..2ce3078ac --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/node_modules/@monorepo/workspaceA @@ -0,0 +1 @@ +../../packages/workspaceA \ No newline at end of file diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/node_modules/@monorepo/workspaceB b/packages/knip/fixtures/workspaces-complex-module-resolution/node_modules/@monorepo/workspaceB new file mode 120000 index 000000000..8d12a2c77 --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/node_modules/@monorepo/workspaceB @@ -0,0 +1 @@ +../../packages/workspaceB \ No newline at end of file diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/package.json b/packages/knip/fixtures/workspaces-complex-module-resolution/package.json new file mode 100644 index 000000000..439349b1e --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/package.json @@ -0,0 +1,6 @@ +{ + "name": "@fixtures/module-resolution-workspaces", + "version": "1.0.0", + "private": true, + "workspaces": ["packages/*"] +} diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/package.json b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/package.json new file mode 100644 index 000000000..bab0e8393 --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/package.json @@ -0,0 +1,10 @@ +{ + "name": "@monorepo/workspaceA", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "dependencies": { + "@monorepo/workspaceB": "*" + } +} diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/src/index.ts b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/src/index.ts new file mode 100644 index 000000000..7b555928c --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/src/index.ts @@ -0,0 +1,3 @@ +import { usedFunction } from '@monorepo/workspaceB'; + +usedFunction; diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/tsconfig.json b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/tsconfig.json new file mode 100644 index 000000000..80dfb3bca --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceA/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src", + "baseUrl": "src", + "declaration": true, + "paths": { + "#dummy": ["src"] + } + }, + "include": ["src/**/*"], + "exclude": ["dist/**/*"] +} diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/package.json b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/package.json new file mode 100644 index 000000000..55fb66032 --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/package.json @@ -0,0 +1,7 @@ +{ + "name": "@monorepo/workspaceB", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts" +} diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/exports.ts b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/exports.ts new file mode 100644 index 000000000..91ca2e1a5 --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/exports.ts @@ -0,0 +1 @@ +export const someFunction = () => 'bar'; diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/index.ts b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/index.ts new file mode 100644 index 000000000..ceb3b686e --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/index.ts @@ -0,0 +1,2 @@ +export * from 'exports'; +export { usedFunction } from 'used-fn'; diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/used-fn.ts b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/used-fn.ts new file mode 100644 index 000000000..fb9b18aae --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/src/used-fn.ts @@ -0,0 +1,6 @@ +import { someFunction } from 'exports'; + +export const usedFunction = () => { + someFunction(); + return 'bar'; +}; diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/tsconfig.json b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/tsconfig.json new file mode 100644 index 000000000..81bc0c371 --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/packages/workspaceB/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src", + "baseUrl": "src", + "declaration": true, + "paths": { + "#dummy-shared": ["src"] + } + }, + "include": ["src/**/*"], + "exclude": ["dist/**/*"] +} diff --git a/packages/knip/fixtures/workspaces-complex-module-resolution/tsconfig.json b/packages/knip/fixtures/workspaces-complex-module-resolution/tsconfig.json new file mode 100644 index 000000000..94c6470ff --- /dev/null +++ b/packages/knip/fixtures/workspaces-complex-module-resolution/tsconfig.json @@ -0,0 +1,19 @@ +{ + "files": [], + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "forceConsistentCasingInFileNames": true, + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "references": [ + { "path": "packages/workspaceA" }, + { "path": "packages/workspaceB" }, + ] +} diff --git a/packages/knip/src/index.ts b/packages/knip/src/index.ts index 89e3f9d3c..08dca1ce5 100644 --- a/packages/knip/src/index.ts +++ b/packages/knip/src/index.ts @@ -291,9 +291,18 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { } }; - const analyzeSourceFile = (filePath: string, principal: ProjectPrincipal) => { + const analyzeSourceFile = (filePath: string | undefined, principal?: ProjectPrincipal | undefined) => { + if (!filePath || analyzedFiles.has(filePath)) { + return; + } + const workspace = chief.findWorkspaceByFilePath(filePath); - if (workspace) { + principal = principal || !workspace ? principal : factory.getPrincipalByPackageName(workspace.pkgName); + + if (workspace && principal) { + // Add to analyzed files early to prevent risk of infinite recursion + analyzedFiles.add(filePath); + const { imports, exports, scripts } = principal.analyzeSourceFile(filePath, { skipTypeOnly: isStrict, isFixExports: fixer.isEnabled && fixer.isFixUnusedExports, @@ -319,11 +328,9 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { const specifiers = _getDependenciesFromScripts(scripts, { cwd, manifestScriptNames, dependencies }); for (const specifier of specifiers) { const specifierFilePath = handleReferencedDependency(specifier, filePath, workspace); - if (specifierFilePath) internalWorkspaceFilePaths.add(specifierFilePath); + analyzeSourceFile(specifierFilePath, principal); } } - - analyzedFiles.add(filePath); } }; @@ -358,19 +365,22 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { } } while (size !== principal.entryPaths.size); - for (const specifierFilePath of internalWorkspaceFilePaths) { - if (!analyzedFiles.has(specifierFilePath)) { - analyzeSourceFile(specifierFilePath, principal); - } - } - for (const filePath of principal.getUnreferencedFiles()) unreferencedFiles.add(filePath); for (const filePath of principal.entryPaths) entryPaths.add(filePath); principal.reconcileCache(serializableMap); + } - // Delete principals including TS programs for GC, except when we still need its `LS.findReferences` - if (isSkipLibs && !isWatch) factory.deletePrincipal(principal); + // Re-analyze files from local workspaces that were referenced, but have not yet been successfully resolved + for (const specifierFilePath of internalWorkspaceFilePaths) { + analyzeSourceFile(specifierFilePath); + } + + // Delete principals including TS programs for GC, except when we still need its `LS.findReferences` + if (isSkipLibs && !isWatch) { + for (const principal of principals) { + factory.deletePrincipal(principal); + } } const isIdentifierReferenced = getIsIdentifierReferencedHandler(serializableMap); diff --git a/packages/knip/src/typescript/resolveModuleNames.ts b/packages/knip/src/typescript/resolveModuleNames.ts index 8936947ac..04758a334 100644 --- a/packages/knip/src/typescript/resolveModuleNames.ts +++ b/packages/knip/src/typescript/resolveModuleNames.ts @@ -38,7 +38,10 @@ export function createCustomModuleResolver( : `${containingFile}:${moduleName}`; if (resolutionCache.has(key)) return resolutionCache.get(key); const resolvedModule = resolveModuleName(moduleName, containingFile); - resolutionCache.set(key, resolvedModule); + // Don't save resolution misses, because it might be resolved later under a different principal + if (resolvedModule) { + resolutionCache.set(key, resolvedModule); + } return resolvedModule; }); } diff --git a/packages/knip/test/workspaces-complex-module-resolution.test.ts b/packages/knip/test/workspaces-complex-module-resolution.test.ts new file mode 100644 index 000000000..237378f12 --- /dev/null +++ b/packages/knip/test/workspaces-complex-module-resolution.test.ts @@ -0,0 +1,22 @@ +import { test } from 'bun:test'; +import assert from 'node:assert/strict'; +import { main } from '../src/index.js'; +import { resolve } from '../src/util/path.js'; +import baseArguments from './helpers/baseArguments.js'; +import baseCounters from './helpers/baseCounters.js'; + +const cwd = resolve('fixtures/workspaces-complex-module-resolution'); + +test('Resolve modules properly across multiple workspaces', async () => { + const { counters } = await main({ + ...baseArguments, + cwd, + isDebug: true, + }); + + assert.deepEqual(counters, { + ...baseCounters, + processed: 4, + total: 4, + }); +});