diff --git a/packages/docs/src/content/docs/reference/cli.md b/packages/docs/src/content/docs/reference/cli.md index 332a61c96..995087537 100644 --- a/packages/docs/src/content/docs/reference/cli.md +++ b/packages/docs/src/content/docs/reference/cli.md @@ -73,19 +73,16 @@ Total running time: 5s (mem: 631.27MB) - `median`: the median invocation - `sum` the accumulated time of all invocations -### `--isolate-workspaces` +This is not yet available in Bun, since it does not support +`performance.timerify` +([GitHub issue](https://github.com/oven-sh/bun/issues/9271)). -By default, Knip optimizes performance by adding eligible workspaces to existing -TypeScript programs, based on the compatibility of their `compilerOptions`. Use -this flag to disable this behavior and create one program per workspace. +### `knip-bun` -You can see the behavior in action in [debug mode][1]. Look for messages like -this: +Run Knip using the Bun runtime (instead of Node.js). -```sh -[*] Installed 4 programs for 18 workspaces -... -[*] Analyzing used resolved files [P1/1] (78) +```shell +knip-bun ``` ## Configuration @@ -170,10 +167,33 @@ mode][6]. Read more at [Production Mode][5]. +### `--isolate-workspaces` + +By default, Knip optimizes performance by adding eligible workspaces to existing +TypeScript programs, based on the compatibility of their `compilerOptions`. Use +this flag to disable this behavior and create one program per workspace. + +You can see the behavior in action in [debug mode][1]. Look for messages like +this: + +```sh +[*] Installed 4 programs for 18 workspaces +... +[*] Analyzing used resolved files [P1/1] (78) +``` + ### `--fix` Read more at [auto-fix][7]. +### `--watch` + +Watch current directory, and update reported issues when a file is modified, +added or deleted. + +Watch mode focuses on imports and exports in source files. During watch mode, +changes in `package.json` and/or `node_modules` are not supported. + ## Filters Available [issue types][8] when filtering output using `--include` or diff --git a/packages/knip/src/ConsoleStreamer.ts b/packages/knip/src/ConsoleStreamer.ts index d1b42f822..97944477a 100644 --- a/packages/knip/src/ConsoleStreamer.ts +++ b/packages/knip/src/ConsoleStreamer.ts @@ -29,9 +29,10 @@ export class ConsoleStreamer { this.lines = messages.length; } - cast(message: string) { + cast(message: string | string[]) { if (!this.isEnabled) return; - this.update([message]); + if (Array.isArray(message)) this.update(message); + else this.update([message]); } clear() { diff --git a/packages/knip/src/IssueCollector.ts b/packages/knip/src/IssueCollector.ts index 2b0f99480..8bb65229c 100644 --- a/packages/knip/src/IssueCollector.ts +++ b/packages/knip/src/IssueCollector.ts @@ -1,8 +1,8 @@ import micromatch from 'micromatch'; import { initCounters, initIssues } from './issues/initializers.js'; import type { ConfigurationHint, Issue, Rules } from './types/issues.js'; -import { relative } from './util/path.js'; import { timerify } from './util/Performance.js'; +import { relative } from './util/path.js'; type Filters = Partial<{ dir: string; @@ -83,6 +83,13 @@ export class IssueCollector { } } + purge() { + const unusedFiles = this.issues.files; + this.issues = initIssues(); + this.counters = initCounters(); + return unusedFiles; + } + getIssues() { return { issues: this.issues, diff --git a/packages/knip/src/PrincipalFactory.ts b/packages/knip/src/PrincipalFactory.ts index d24748846..0045958d2 100644 --- a/packages/knip/src/PrincipalFactory.ts +++ b/packages/knip/src/PrincipalFactory.ts @@ -18,6 +18,7 @@ export type PrincipalOptions = { isGitIgnored: (path: string) => boolean; isIsolateWorkspaces: boolean; isSkipLibs: boolean; + isWatch: boolean; }; const mapToAbsolutePaths = (paths: NonNullable, cwd: string): Paths => diff --git a/packages/knip/src/ProjectPrincipal.ts b/packages/knip/src/ProjectPrincipal.ts index e20de48e1..58173e20e 100644 --- a/packages/knip/src/ProjectPrincipal.ts +++ b/packages/knip/src/ProjectPrincipal.ts @@ -70,6 +70,7 @@ export class ProjectPrincipal { syncCompilers: SyncCompilers; asyncCompilers: AsyncCompilers; isSkipLibs: boolean; + isWatch: boolean; cache: CacheConsultant; @@ -85,7 +86,7 @@ export class ProjectPrincipal { findReferences?: ts.LanguageService['findReferences']; - constructor({ compilerOptions, cwd, compilers, isGitIgnored, isSkipLibs }: PrincipalOptions, n: number) { + constructor({ compilerOptions, cwd, compilers, isGitIgnored, isSkipLibs, isWatch }: PrincipalOptions, n: number) { this.cwd = cwd; this.isGitIgnored = isGitIgnored; @@ -102,6 +103,7 @@ export class ProjectPrincipal { this.syncCompilers = syncCompilers; this.asyncCompilers = asyncCompilers; this.isSkipLibs = isSkipLibs; + this.isWatch = isWatch; this.cache = new CacheConsultant(`project-${n}`); } @@ -113,6 +115,7 @@ export class ProjectPrincipal { entryPaths: this.entryPaths, compilers: [this.syncCompilers, this.asyncCompilers], isSkipLibs: this.isSkipLibs, + useResolverCache: !this.isWatch, }); this.backend = { @@ -168,9 +171,19 @@ export class ProjectPrincipal { public addProjectPath(filePath: string) { if (!isInNodeModules(filePath) && this.hasAcceptedExtension(filePath)) { this.projectPaths.add(filePath); + this.deletedFiles.delete(filePath); } } + // TODO Organize better + deletedFiles = new Set(); + public removeProjectPath(filePath: string) { + this.entryPaths.delete(filePath); + this.projectPaths.delete(filePath); + this.invalidateFile(filePath); + this.deletedFiles.add(filePath); + } + public addReferencedDependencies(workspaceName: string, referencedDependencies: ReferencedDependencies) { for (const referencedDependency of referencedDependencies) this.referencedDependencies.add([...referencedDependency, workspaceName]); @@ -205,6 +218,21 @@ export class ProjectPrincipal { return Array.from(this.projectPaths).filter(filePath => !sourceFiles.has(filePath)); } + private getResolvedModuleHandler(sourceFile: BoundSourceFile) { + const getResolvedModule = this.backend.program?.getResolvedModule; + const resolver = getResolvedModule + ? (specifier: string) => getResolvedModule(sourceFile, specifier, /* mode */ undefined) + : (specifier: string) => sourceFile.resolvedModules?.get(specifier, /* mode */ undefined); + if (!this.isWatch) return resolver; + + // TODO It's either this awkward bit in watch mode to handle deleted files, or some large refactoring + return (specifier: string) => { + const m = resolver(specifier); + if (m?.resolvedModule?.resolvedFileName && this.deletedFiles.has(m.resolvedModule.resolvedFileName)) return; + return m; + }; + } + public analyzeSourceFile(filePath: string, options: Omit) { const fd = this.cache.getFileDescriptor(filePath); if (!fd.changed && fd.meta?.data) return deserialize(fd.meta.data); @@ -218,14 +246,9 @@ export class ProjectPrincipal { const skipExports = this.skipExportsAnalysis.has(filePath); - const getResolvedModule: GetResolvedModule = specifier => - this.backend.program?.getResolvedModule - ? this.backend.program.getResolvedModule(sourceFile, specifier, /* mode */ undefined) - : sourceFile.resolvedModules?.get(specifier, /* mode */ undefined); - const { imports, exports, scripts } = _getImportsAndExports( sourceFile, - getResolvedModule, + this.getResolvedModuleHandler(sourceFile), this.backend.typeChecker, { ...options, skipExports } ); @@ -278,6 +301,11 @@ export class ProjectPrincipal { }; } + invalidateFile(filePath: string) { + this.backend.fileManager.snapshotCache.delete(filePath); + this.backend.fileManager.sourceFileCache.delete(filePath); + } + public resolveModule(specifier: string, filePath: string = specifier) { return this.backend.resolveModuleNames([specifier], filePath)[0]; } @@ -321,7 +349,7 @@ export class ProjectPrincipal { reconcileCache(serializableMap: SerializableMap) { for (const filePath in serializableMap) { const fd = this.cache.getFileDescriptor(filePath); - if (!fd || !fd.meta) continue; + if (!(fd?.meta)) continue; fd.meta.data = serialize(serializableMap[filePath]); } this.cache.reconcile(); diff --git a/packages/knip/src/cli.ts b/packages/knip/src/cli.ts index 07ba8c87a..8782c6548 100644 --- a/packages/knip/src/cli.ts +++ b/packages/knip/src/cli.ts @@ -2,11 +2,10 @@ import picocolors from 'picocolors'; import prettyMilliseconds from 'pretty-ms'; import { main } from './index.js'; import type { IssueType, ReporterOptions } from './types/issues.js'; -import { Performance } from './util/Performance.js'; +import { perfObserver } from './util/Performance.js'; import parsedArgValues, { helpText } from './util/cli-arguments.js'; import { getKnownError, hasCause, isConfigurationError, isKnownError } from './util/errors.js'; import { cwd } from './util/path.js'; -import './util/register.js'; import { runPreprocessors, runReporters } from './util/reporter.js'; import { splitTags } from './util/tag.js'; import { version } from './version.js'; @@ -23,7 +22,6 @@ const { 'include-entry-exports': isIncludeEntryExports = false, 'include-libs': isIncludeLibs = false, 'isolate-workspaces': isIsolateWorkspaces = false, - performance: isObservePerf = false, production: isProduction = false, 'reporter-options': reporterOptions = '', 'preprocessor-options': preprocessorOptions = '', @@ -34,6 +32,7 @@ const { version: isVersion, 'experimental-tags': experimentalTags = [], tags = [], + watch: isWatch = false, } = parsedArgValues; if (isHelp) { @@ -50,23 +49,25 @@ const isShowProgress = isNoProgress === false && process.stdout.isTTY && typeof const run = async () => { try { - const perfObserver = new Performance(isObservePerf); - const { report, issues, counters, rules, configurationHints } = await main({ cwd, tsConfigFile: tsConfig, gitignore: !isNoGitIgnore, + isDebug, isProduction: isStrict || isProduction, isStrict, isShowProgress, isIncludeEntryExports, isIncludeLibs, isIsolateWorkspaces, + isWatch, tags: tags.length > 0 ? splitTags(tags) : splitTags(experimentalTags), isFix: isFix || fixTypes.length > 0, fixTypes: fixTypes.flatMap(type => type.split(',')), }); + if (isWatch) return; + const initialData: ReporterOptions = { report, issues, @@ -88,11 +89,12 @@ const run = async () => { .filter(reportGroup => finalData.report[reportGroup] && rules[reportGroup] === 'error') .reduce((errorCount: number, reportGroup) => errorCount + finalData.counters[reportGroup], 0); - if (isObservePerf) { + if (perfObserver.isEnabled) { await perfObserver.finalize(); console.log(`\n${perfObserver.getTable()}`); - const mem = Math.round((perfObserver.getMemHeapUsage() / 1024 / 1024) * 100) / 100; - console.log('\nTotal running time:', prettyMilliseconds(perfObserver.getTotalTime()), `(mem: ${mem}MB)`); + const mem = perfObserver.getCurrentMemUsageInMb(); + const duration = perfObserver.getCurrentDurationInMs(); + console.log('\nTotal running time:', prettyMilliseconds(duration), `(mem: ${mem}MB)`); perfObserver.reset(); } diff --git a/packages/knip/src/index.ts b/packages/knip/src/index.ts index e70b80100..55f1f54e3 100644 --- a/packages/knip/src/index.ts +++ b/packages/knip/src/index.ts @@ -1,3 +1,4 @@ +import { watch } from 'node:fs'; import { CacheConsultant } from './CacheConsultant.js'; import { ConfigurationChief } from './ConfigurationChief.js'; import { ConsoleStreamer } from './ConsoleStreamer.js'; @@ -10,6 +11,7 @@ import { WorkspaceWorker } from './WorkspaceWorker.js'; import { _getDependenciesFromScripts } from './binaries/index.js'; import { getCompilerExtensions, getIncludedCompilers } from './compilers/index.js'; import { getFilteredScripts } from './manifest/helpers.js'; +import watchReporter from './reporters/watch.js'; import type { CommandLineOptions } from './types/cli.js'; import type { SerializableExport, @@ -19,6 +21,7 @@ import type { SerializableMap, } from './types/map.js'; import { debugLog, debugLogArray, debugLogObject } from './util/debug.js'; +import { isFile } from './util/fs.js'; import { getReExportingEntryFileHandler } from './util/get-reexporting-entry-file.js'; import { _glob, negate } from './util/glob.js'; import { getGitIgnoredFn } from './util/globby.js'; @@ -45,6 +48,8 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { isIncludeEntryExports, isIncludeLibs, isIsolateWorkspaces, + isDebug, + isWatch, tags, isFix, fixTypes, @@ -120,6 +125,7 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { isGitIgnored, isIsolateWorkspaces, isSkipLibs, + isWatch, }); const worker = new WorkspaceWorker({ @@ -242,97 +248,102 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { const unreferencedFiles = new Set(); const entryPaths = new Set(); - for (const principal of principals) { - principal.init(); - - const handleReferencedDependency = getHandler(collector, deputy, chief); - - for (const [containingFilePath, specifier, workspaceName] of principal.referencedDependencies) { - const workspace = chief.findWorkspaceByName(workspaceName); - if (workspace) handleReferencedDependency(specifier, containingFilePath, workspace, principal); - } - - const specifierFilePaths = new Set(); - - const analyzeSourceFile = (filePath: string, _principal: ProjectPrincipal = principal) => { - const workspace = chief.findWorkspaceByFilePath(filePath); - if (workspace) { - const { imports, exports, scripts } = _principal.analyzeSourceFile(filePath, { - skipTypeOnly: isStrict, - isFixExports: fixer.isEnabled && fixer.isFixUnusedExports, - isFixTypes: fixer.isEnabled && fixer.isFixUnusedTypes, - ignoreExportsUsedInFile: Boolean(chief.config.ignoreExportsUsedInFile), - isReportClassMembers, - tags, - }); - const { internal, external, unresolved } = imports; - const { exported, duplicate } = exports; - - if (Object.keys(exported).length > 0) exportedSymbols[filePath] = exported; - - for (const [specifierFilePath, importItems] of Object.entries(internal)) { - const packageName = getPackageNameFromModuleSpecifier(importItems.specifier); - if (packageName && chief.availableWorkspacePkgNames.has(packageName)) { - // Mark "external" imports from other local workspaces as used dependency - external.add(packageName); - if (_principal === principal) { - const workspace = chief.findWorkspaceByFilePath(specifierFilePath); - if (workspace) { - const principal = factory.getPrincipalByPackageName(workspace.pkgName); - if (principal && !isGitIgnored(specifierFilePath)) { - // Defer to outside loop to prevent potential duplicate analysis and/or infinite recursion - specifierFilePaths.add(specifierFilePath); - } - } - } - } - - if (!importedSymbols[specifierFilePath]) { - importedSymbols[specifierFilePath] = importItems; - } else { - const importedModule = importedSymbols[specifierFilePath]; - for (const id of importItems.identifiers) importedModule.identifiers.add(id); - for (const id of importItems.importedNs) importedModule.importedNs.add(id); - for (const id of importItems.isReExportedBy) importedModule.isReExportedBy.add(id); - for (const id of importItems.isReExportedNs) importedModule.isReExportedNs.add(id); - if (importItems.hasStar) importedModule.hasStar = true; - if (importItems.isReExport) importedModule.isReExport = true; + const handleReferencedDependency = getHandler(collector, deputy, chief); + + const updateImports = (importedModule: SerializableImports, importItems: SerializableImports) => { + for (const id of importItems.identifiers) importedModule.identifiers.add(id); + for (const id of importItems.importedNs) importedModule.importedNs.add(id); + for (const id of importItems.isReExportedBy) importedModule.isReExportedBy.add(id); + for (const id of importItems.isReExportedNs) importedModule.isReExportedNs.add(id); + for (const id of importItems.by) importedModule.by.add(id); + if (importItems.hasStar) importedModule.hasStar = true; + if (importItems.isReExport) importedModule.isReExport = true; + }; + + const updateImported = (filePath: string, importItems: SerializableImports) => { + serializableMap[filePath] = serializableMap[filePath] || {}; + const importedFile = serializableMap[filePath]; + if (!importedFile.imported) importedFile.imported = importItems; + else updateImports(importedFile.imported, importItems); + }; + + const setInternalImports = (filePath: string, internalImports: SerializableImportMap) => { + for (const [specifierFilePath, importItems] of Object.entries(internalImports)) { + const packageName = getPackageNameFromModuleSpecifier(importItems.specifier); + if (packageName && chief.availableWorkspacePkgNames.has(packageName)) { + // Mark "external" imports from other local workspaces as used dependency + serializableMap[filePath].imports.external.add(packageName); + const workspace = chief.findWorkspaceByFilePath(specifierFilePath); + if (workspace) { + const principal = factory.getPrincipalByPackageName(workspace.pkgName); + if (principal && !isGitIgnored(specifierFilePath)) { + // Defer to outside loop to prevent potential duplicate analysis and/or infinite recursion + internalWorkspaceFilePaths.add(specifierFilePath); } } + } - for (const symbols of duplicate) { - if (symbols.length > 1) { - const symbol = symbols.map(s => s.symbol).join('|'); - collector.addIssue({ type: 'duplicates', filePath, symbol, symbols }); - } - } + const s = serializableMap[filePath]; + if (!s.imports.internal[specifierFilePath]) s.imports.internal[specifierFilePath] = importItems; + else updateImports(s.imports.internal[specifierFilePath], importItems); - for (const specifier of external) { - const packageName = getPackageNameFromModuleSpecifier(specifier); - const isHandled = packageName && deputy.maybeAddReferencedExternalDependency(workspace, packageName); - if (!isHandled) collector.addIssue({ type: 'unlisted', filePath, symbol: specifier }); + updateImported(specifierFilePath, importItems); + } + }; + + const internalWorkspaceFilePaths = new Set(); + + const analyzeSourceFile = (filePath: string, principal: ProjectPrincipal) => { + const workspace = chief.findWorkspaceByFilePath(filePath); + if (workspace) { + const { imports, exports, scripts } = principal.analyzeSourceFile(filePath, { + skipTypeOnly: isStrict, + isFixExports: fixer.isEnabled && fixer.isFixUnusedExports, + isFixTypes: fixer.isEnabled && fixer.isFixUnusedTypes, + ignoreExportsUsedInFile: Boolean(chief.config.ignoreExportsUsedInFile), + isReportClassMembers, + tags, + }); + + serializableMap[filePath] = serializableMap[filePath] || {}; + serializableMap[filePath].internalImportCache = imports.internal; + serializableMap[filePath].imports = { ...imports, internal: {} }; + serializableMap[filePath].exports = exports; + serializableMap[filePath].scripts = scripts; + + setInternalImports(filePath, imports.internal); + + if (scripts.size > 0) { + const cwd = dirname(filePath); + const dependencies = deputy.getDependencies(workspace.name); + const manifestScriptNames = new Set(); + const specifiers = _getDependenciesFromScripts(scripts, { cwd, manifestScriptNames, dependencies }); + for (const specifier of specifiers) { + const specifierFilePath = handleReferencedDependency(specifier, filePath, workspace); + if (specifierFilePath) internalWorkspaceFilePaths.add(specifierFilePath); } + } - for (const unresolvedImport of unresolved) { - const { specifier, pos, line, col } = unresolvedImport; - collector.addIssue({ type: 'unresolved', filePath, symbol: specifier, pos, line, col }); - } + analyzedFiles.add(filePath); + } + }; - for (const specifier of _getDependenciesFromScripts(scripts, { - cwd: dirname(filePath), - manifestScriptNames: new Set(), - dependencies: deputy.getDependencies(workspace.name), - })) { - handleReferencedDependency(specifier, filePath, workspace, _principal); - } + for (const principal of principals) { + principal.init(); + + for (const [containingFilePath, specifier, workspaceName] of principal.referencedDependencies) { + const workspace = chief.findWorkspaceByName(workspaceName); + if (workspace) { + const specifierFilePath = handleReferencedDependency(specifier, containingFilePath, workspace); + if (specifierFilePath) principal.addEntryPath(specifierFilePath); } - }; + } streamer.cast('Running async compilers...'); await principal.runAsyncCompilers(); - streamer.cast('Connecting the dots...'); + streamer.cast('Analyzing source files...'); let size = principal.entryPaths.size; let round = 0; @@ -344,14 +355,12 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { debugLogArray('*', `Analyzing used resolved files [P${principals.indexOf(principal) + 1}/${++round}]`, files); for (const filePath of files) { - analyzeSourceFile(filePath); - analyzedFiles.add(filePath); + analyzeSourceFile(filePath, principal); } } while (size !== principal.entryPaths.size); - for (const specifierFilePath of specifierFilePaths) { + for (const specifierFilePath of internalWorkspaceFilePaths) { if (!analyzedFiles.has(specifierFilePath)) { - analyzedFiles.add(specifierFilePath); analyzeSourceFile(specifierFilePath, principal); } } @@ -362,12 +371,12 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { principal.reconcileCache(serializableMap); // Delete principals including TS programs for GC, except when we still need its `LS.findReferences` - if (isSkipLibs) factory.deletePrincipal(principal); + if (isSkipLibs && !isWatch) factory.deletePrincipal(principal); } - const isIdentifierReferenced = getIsIdentifierReferencedHandler(importedSymbols); + const isIdentifierReferenced = getIsIdentifierReferencedHandler(serializableMap); - const getReExportingEntryFile = getReExportingEntryFileHandler(entryPaths, exportedSymbols, importedSymbols); + const getReExportingEntryFile = getReExportingEntryFileHandler(entryPaths, serializableMap); const isExportedItemReferenced = (exportedItem: SerializableExport | SerializableExportMember) => exportedItem.refs > 0 && @@ -375,133 +384,254 @@ export const main = async (unresolvedConfiguration: CommandLineOptions) => { ? exportedItem.type !== 'unknown' && !!chief.config.ignoreExportsUsedInFile[exportedItem.type] : chief.config.ignoreExportsUsedInFile); - if (isReportValues || isReportTypes) { - streamer.cast('Analyzing source files...'); + const findUnusedExports = async () => { + if (isReportValues || isReportTypes) { + streamer.cast('Connecting the dots...'); - for (const [filePath, exportItems] of Object.entries(exportedSymbols)) { - const workspace = chief.findWorkspaceByFilePath(filePath); - const principal = workspace && factory.getPrincipalByPackageName(workspace.pkgName); + for (const [filePath, file] of Object.entries(serializableMap)) { + const exportItems = file.exports?.exported; + if (!exportItems) continue; + const workspace = chief.findWorkspaceByFilePath(filePath); + const principal = workspace && factory.getPrincipalByPackageName(workspace.pkgName); - if (workspace) { - const { isIncludeEntryExports } = workspace.config; + if (workspace) { + const { isIncludeEntryExports } = workspace.config; - // Bail out when in entry file (unless `isIncludeEntryExports`) - if (!isIncludeEntryExports && entryPaths.has(filePath)) continue; + // Bail out when in entry file (unless `isIncludeEntryExports`) + if (!isIncludeEntryExports && entryPaths.has(filePath)) continue; - const importsForExport = importedSymbols[filePath]; + const importsForExport = file.imported; - for (const [identifier, exportedItem] of Object.entries(exportItems)) { - // Skip tagged exports - if (exportedItem.jsDocTags.includes('@public') || exportedItem.jsDocTags.includes('@beta')) continue; - if (exportedItem.jsDocTags.includes('@alias')) continue; - if (shouldIgnore(exportedItem.jsDocTags, tags)) continue; - if (isProduction && exportedItem.jsDocTags.includes('@internal')) continue; + for (const [identifier, exportedItem] of Object.entries(exportItems)) { + // Skip tagged exports + if (exportedItem.jsDocTags.has('@public') || exportedItem.jsDocTags.has('@beta')) continue; + if (exportedItem.jsDocTags.has('@alias')) continue; + if (shouldIgnore(exportedItem.jsDocTags, tags)) continue; + if (isProduction && exportedItem.jsDocTags.has('@internal')) continue; - if (importsForExport) { - if (!isIncludeEntryExports) { - if (getReExportingEntryFile(importsForExport, identifier, 0, filePath)) continue; - } + if (importsForExport) { + if (!isIncludeEntryExports) { + if (getReExportingEntryFile(importsForExport, identifier, 0, filePath)) continue; + } - if (isIdentifierReferenced(filePath, identifier, importsForExport)) { - if (report.enumMembers && exportedItem.type === 'enum') { - for (const member of exportedItem.members) { - if (hasMatch(workspace.ignoreMembers, member.identifier)) continue; - if (shouldIgnore(member.jsDocTags, tags)) continue; - - if (member.refs === 0) { - if (!isIdentifierReferenced(filePath, `${identifier}.${member.identifier}`, importsForExport)) { - collector.addIssue({ - type: 'enumMembers', - filePath, - symbol: member.identifier, - parentSymbol: identifier, - pos: member.pos, - line: member.line, - col: member.col, - }); + if (isIdentifierReferenced(filePath, identifier, importsForExport)) { + if (report.enumMembers && exportedItem.type === 'enum') { + for (const member of exportedItem.members) { + if (hasMatch(workspace.ignoreMembers, member.identifier)) continue; + if (shouldIgnore(member.jsDocTags, tags)) continue; + + if (member.refs === 0) { + if (!isIdentifierReferenced(filePath, `${identifier}.${member.identifier}`, importsForExport)) { + collector.addIssue({ + type: 'enumMembers', + filePath, + symbol: member.identifier, + parentSymbol: identifier, + pos: member.pos, + line: member.line, + col: member.col, + }); + } } } } - } - if (principal && isReportClassMembers && exportedItem.type === 'class') { - const members = exportedItem.members.filter( - member => - !(hasMatch(workspace.ignoreMembers, member.identifier) || shouldIgnore(member.jsDocTags, tags)) - ); - for (const member of principal.findUnusedMembers(filePath, members)) { - collector.addIssue({ - type: 'classMembers', - filePath, - symbol: member.identifier, - parentSymbol: exportedItem.identifier, - pos: member.pos, - line: member.line, - col: member.col, - }); + if (principal && isReportClassMembers && exportedItem.type === 'class') { + const members = exportedItem.members.filter( + member => + !(hasMatch(workspace.ignoreMembers, member.identifier) || shouldIgnore(member.jsDocTags, tags)) + ); + for (const member of principal.findUnusedMembers(filePath, members)) { + collector.addIssue({ + type: 'classMembers', + filePath, + symbol: member.identifier, + parentSymbol: exportedItem.identifier, + pos: member.pos, + line: member.line, + col: member.col, + }); + } } - } - // This id was imported, so we bail out early - continue; + // This id was imported, so we bail out early + continue; + } } - } - const [hasStrictlyNsReferences, namespace] = getHasStrictlyNsReferences(importsForExport); + const [hasStrictlyNsReferences, namespace] = getHasStrictlyNsReferences(importsForExport); - const isType = ['enum', 'type', 'interface'].includes(exportedItem.type); + const isType = ['enum', 'type', 'interface'].includes(exportedItem.type); - if (hasStrictlyNsReferences && ((!report.nsTypes && isType) || !(report.nsExports || isType))) continue; + if (hasStrictlyNsReferences && ((!report.nsTypes && isType) || !(report.nsExports || isType))) continue; - if (!isExportedItemReferenced(exportedItem)) { - if (!isSkipLibs && principal?.hasReferences(filePath, exportedItem)) continue; + if (!isExportedItemReferenced(exportedItem)) { + if (!isSkipLibs && principal?.hasReferences(filePath, exportedItem)) continue; - const type = getType(hasStrictlyNsReferences, isType); - collector.addIssue({ - type, - filePath, - symbol: identifier, - symbolType: exportedItem.type, - parentSymbol: namespace, - pos: exportedItem.pos, - line: exportedItem.line, - col: exportedItem.col, - }); - if (isType) fixer.addUnusedTypeNode(filePath, exportedItem.fixes); - else fixer.addUnusedExportNode(filePath, exportedItem.fixes); + const type = getType(hasStrictlyNsReferences, isType); + collector.addIssue({ + type, + filePath, + symbol: identifier, + symbolType: exportedItem.type, + parentSymbol: namespace, + pos: exportedItem.pos, + line: exportedItem.line, + col: exportedItem.col, + }); + if (isType) fixer.addUnusedTypeNode(filePath, exportedItem.fixes); + else fixer.addUnusedExportNode(filePath, exportedItem.fixes); + } } } } } - } - const unusedFiles = [...unreferencedFiles].filter(filePath => !analyzedFiles.has(filePath)); + for (const [filePath, file] of Object.entries(serializableMap)) { + if (file.exports?.duplicate) { + for (const symbols of file.exports.duplicate) { + if (symbols.length > 1) { + const symbol = symbols.map(s => s.symbol).join('|'); + collector.addIssue({ type: 'duplicates', filePath, symbol, symbols }); + } + } + } + + if (file.imports?.external) { + const workspace = chief.findWorkspaceByFilePath(filePath); + if (workspace) { + for (const specifier of file.imports.external) { + const packageName = getPackageNameFromModuleSpecifier(specifier); + const isHandled = packageName && deputy.maybeAddReferencedExternalDependency(workspace, packageName); + if (!isHandled) collector.addIssue({ type: 'unlisted', filePath, symbol: specifier }); + } + } + } - collector.addFilesIssues(unusedFiles); + if (file.imports?.unresolved) { + for (const unresolvedImport of file.imports.unresolved) { + const { specifier, pos, line, col } = unresolvedImport; + collector.addIssue({ type: 'unresolved', filePath, symbol: specifier, pos, line, col }); + } + } + } - collector.addFileCounts({ processed: analyzedFiles.size, unused: unusedFiles.length }); + const unusedFiles = [...unreferencedFiles].filter(filePath => !analyzedFiles.has(filePath)); - if (isReportDependencies) { - const { dependencyIssues, devDependencyIssues, optionalPeerDependencyIssues } = deputy.settleDependencyIssues(); - const { configurationHints } = deputy.getConfigurationHints(); - for (const issue of dependencyIssues) collector.addIssue(issue); - if (!isProduction) for (const issue of devDependencyIssues) collector.addIssue(issue); - for (const issue of optionalPeerDependencyIssues) collector.addIssue(issue); - for (const hint of configurationHints) collector.addConfigurationHint(hint); - } + collector.addFilesIssues(unusedFiles); - const unusedIgnoredWorkspaces = chief.getUnusedIgnoredWorkspaces(); - for (const identifier of unusedIgnoredWorkspaces) { - collector.addConfigurationHint({ type: 'ignoreWorkspaces', identifier }); + collector.addFileCounts({ processed: analyzedFiles.size, unused: unusedFiles.length }); + + if (isReportDependencies) { + const { dependencyIssues, devDependencyIssues, optionalPeerDependencyIssues } = deputy.settleDependencyIssues(); + const { configurationHints } = deputy.getConfigurationHints(); + for (const issue of dependencyIssues) collector.addIssue(issue); + if (!isProduction) for (const issue of devDependencyIssues) collector.addIssue(issue); + for (const issue of optionalPeerDependencyIssues) collector.addIssue(issue); + for (const hint of configurationHints) collector.addConfigurationHint(hint); + } + + const unusedIgnoredWorkspaces = chief.getUnusedIgnoredWorkspaces(); + for (const identifier of unusedIgnoredWorkspaces) { + collector.addConfigurationHint({ type: 'ignoreWorkspaces', identifier }); + } + }; + + if (isWatch) { + watch('.', { recursive: true }, async (eventType, filename) => { + if (filename) { + const startTime = performance.now(); + const filePath = join(cwd, filename); + + if (filename.startsWith(CacheConsultant.getCacheLocation())) return; + if (isGitIgnored(filePath)) return; + + const workspace = chief.findWorkspaceByFilePath(filePath); + if (workspace) { + const principal = factory.getPrincipalByPackageName(workspace.pkgName); + if (principal) { + const event = eventType === 'rename' ? (isFile(filePath) ? 'added' : 'deleted') : 'modified'; + + principal.invalidateFile(filePath); + internalWorkspaceFilePaths.clear(); + unreferencedFiles.clear(); + const cachedUnusedFiles = collector.purge(); + + switch (event) { + case 'added': + principal.addProjectPath(filePath); + principal.deletedFiles.delete(filePath); + cachedUnusedFiles.add(filePath); + debugLog(workspace.name, `Watcher: + ${filename}`); + break; + case 'deleted': + analyzedFiles.delete(filePath); + principal.removeProjectPath(filePath); + cachedUnusedFiles.delete(filePath); + debugLog(workspace.name, `Watcher: - ${filename}`); + break; + case 'modified': + debugLog(workspace.name, `Watcher: ± ${filename}`); + break; + } + + const filePaths = principal.getUsedResolvedFiles(); + + if (event === 'added' || event === 'deleted') { + // Flush, any file might contain (un)resolved imports to added/deleted files + serializableMap = {}; + for (const filePath of filePaths) analyzeSourceFile(filePath, principal); + } else { + for (const filePath in serializableMap) { + if (filePaths.includes(filePath)) { + // Reset dep graph + serializableMap[filePath].imported = undefined; + } else { + // Remove files no longer referenced + delete serializableMap[filePath]; + analyzedFiles.delete(filePath); + } + } + + // Add existing files that were not yet part of the program + for (const filePath of filePaths) if (!serializableMap[filePath]) analyzeSourceFile(filePath, principal); + + analyzeSourceFile(filePath, principal); + + // Rebuild dep graph + for (const filePath of filePaths) { + if (serializableMap[filePath]?.internalImportCache) { + // biome-ignore lint/style/noNonNullAssertion: ignore + setInternalImports(filePath, serializableMap[filePath].internalImportCache!); + } + } + } + + await findUnusedExports(); + + const unusedFiles = [...cachedUnusedFiles].filter(filePath => !analyzedFiles.has(filePath)); + collector.addFilesIssues(unusedFiles); + collector.addFileCounts({ processed: analyzedFiles.size, unused: unusedFiles.length }); + + const { issues } = collector.getIssues(); + + watchReporter({ report, issues, streamer, startTime, size: analyzedFiles.size, isDebug }); + } + } + } + }); } + await findUnusedExports(); + const { issues, counters, configurationHints } = collector.getIssues(); if (isFix) { await fixer.fixIssues(issues); } - streamer.clear(); + if (isWatch) watchReporter({ report, issues, streamer, size: analyzedFiles.size, isDebug }); + else streamer.clear(); return { report, issues, counters, rules, configurationHints }; }; diff --git a/packages/knip/src/reporters/watch.ts b/packages/knip/src/reporters/watch.ts new file mode 100644 index 000000000..17616005f --- /dev/null +++ b/packages/knip/src/reporters/watch.ts @@ -0,0 +1,62 @@ +import picocolors from 'picocolors'; +import prettyMilliseconds from 'pretty-ms'; +import type { Entries } from 'type-fest'; +import type { ConsoleStreamer } from '../ConsoleStreamer.js'; +import type { IssueSet, Issues, Report } from '../types/issues.js'; +import { perfObserver } from '../util/Performance.js'; +import { relative } from '../util/path.js'; +import { getTitle } from './util.js'; + +interface WatchReporter { + report: Report; + issues: Issues; + streamer: ConsoleStreamer; + startTime?: number; + size: number; + isDebug: boolean; +} + +export default ({ report, issues, streamer, startTime, size, isDebug }: WatchReporter) => { + const reportMultipleGroups = Object.values(report).filter(Boolean).length > 1; + let totalIssues = 0; + const lines: string[] = []; + for (const [reportType, isReportType] of Object.entries(report) as Entries) { + if (isReportType) { + const title = reportMultipleGroups && getTitle(reportType); + const isSet = issues[reportType] instanceof Set; + const issuesForType = isSet + ? Array.from(issues[reportType] as IssueSet) + : Object.values(issues[reportType]).flatMap(Object.values); + + if (issuesForType.length > 0) { + if (title) { + lines.push(`${picocolors.bold(picocolors.yellow(picocolors.underline(title)))} (${issuesForType.length})`); + } + if (typeof issuesForType[0] === 'string') { + lines.push(...issuesForType.map(filePath => relative(filePath))); + } else { + const width = issuesForType.reduce((max, issue) => Math.max(max, issue.symbol.length), 0) + 1; + for (const issue of issuesForType) { + const filePath = relative(issue.filePath); + const pos = issue.line ? `:${issue.line}:${issue.col}` : ''; + lines.push(`${issue.symbol.padEnd(width)} ${filePath}${pos}`); + } + } + } + + totalIssues = totalIssues + issuesForType.length; + } + } + + const mem = perfObserver.getCurrentMemUsageInMb(); + const duration = perfObserver.getCurrentDurationInMs(startTime); + const summary = `${size} files in ${prettyMilliseconds(duration)} (${mem}MB)`; + + const messages = + totalIssues === 0 + ? ['✂️ Excellent, Knip found no issues.', '', picocolors.gray(summary)] + : [...lines, '', picocolors.gray(summary)]; + + if (isDebug) console.log(messages.join('\n')); + else streamer.cast(messages); +}; diff --git a/packages/knip/src/types/cli.ts b/packages/knip/src/types/cli.ts index b10e9ba7e..8275eae10 100644 --- a/packages/knip/src/types/cli.ts +++ b/packages/knip/src/types/cli.ts @@ -2,12 +2,14 @@ export interface CommandLineOptions { cwd: string; tsConfigFile?: string; gitignore: boolean; + isDebug: boolean; isStrict: boolean; isProduction: boolean; isShowProgress: boolean; isIncludeEntryExports: boolean; isIncludeLibs: boolean; isIsolateWorkspaces: boolean; + isWatch: boolean; isCache?: boolean; cacheLocation?: string; tags: Tags; diff --git a/packages/knip/src/typescript/createHosts.ts b/packages/knip/src/typescript/createHosts.ts index 7e2b95281..07d71f97e 100644 --- a/packages/knip/src/typescript/createHosts.ts +++ b/packages/knip/src/typescript/createHosts.ts @@ -17,13 +17,21 @@ type CreateHostsOptions = { entryPaths: Set; compilers: [SyncCompilers, AsyncCompilers]; isSkipLibs: boolean; + useResolverCache: boolean; }; -export const createHosts = ({ cwd, compilerOptions, entryPaths, compilers, isSkipLibs }: CreateHostsOptions) => { +export const createHosts = ({ + cwd, + compilerOptions, + entryPaths, + compilers, + isSkipLibs, + useResolverCache, +}: CreateHostsOptions) => { const fileManager = new SourceFileManager({ compilers, isSkipLibs }); const compilerExtensions = getCompilerExtensions(compilers); const sys = createCustomSys(cwd, [...compilerExtensions, ...FOREIGN_FILE_EXTENSIONS]); - const resolveModuleNames = createCustomModuleResolver(sys, compilerOptions, compilerExtensions); + const resolveModuleNames = createCustomModuleResolver(sys, compilerOptions, compilerExtensions, useResolverCache); const languageServiceHost: ts.LanguageServiceHost = { getCompilationSettings: () => compilerOptions, diff --git a/packages/knip/src/util/Performance.ts b/packages/knip/src/util/Performance.ts index 19463fb3a..87a20a108 100644 --- a/packages/knip/src/util/Performance.ts +++ b/packages/knip/src/util/Performance.ts @@ -15,7 +15,7 @@ export const timerify = any>(fn: T, name: strin return performance.timerify(Object.defineProperty(fn, 'name', { get: () => name })); }; -export class Performance { +class Performance { isEnabled: boolean; startTime = 0; endTime = 0; @@ -24,7 +24,6 @@ export class Performance { fnObserver?: PerformanceObserver; gcObserver?: PerformanceObserver; memoryUsageStart?: ReturnType; - memoryUsageEnd?: ReturnType; constructor(isEnabled: boolean) { if (isEnabled) { @@ -102,18 +101,20 @@ export class Performance { return table.toString().trim(); } - getTotalTime() { - return this.endTime - this.startTime; + getCurrentDurationInMs(startTime?: number) { + return performance.now() - (startTime ?? this.startTime); } getMemHeapUsage() { - return (this.memoryUsageEnd?.heapUsed ?? 0) - (this.memoryUsageStart?.heapUsed ?? 0); + return (memoryUsage().heapUsed ?? 0) - (this.memoryUsageStart?.heapUsed ?? 0); + } + + getCurrentMemUsageInMb() { + return Math.round((this.getMemHeapUsage() / 1024 / 1024) * 100) / 100; } public async finalize() { if (!this.isEnabled) return; - this.endTime = performance.now(); - this.memoryUsageEnd = memoryUsage(); // Workaround to get all entries await this.flush(); } @@ -123,3 +124,5 @@ export class Performance { this.fnObserver?.disconnect(); } } + +export const perfObserver = new Performance(isEnabled); diff --git a/packages/knip/src/util/cli-arguments.ts b/packages/knip/src/util/cli-arguments.ts index 4f20daed1..da9dc9e8f 100644 --- a/packages/knip/src/util/cli-arguments.ts +++ b/packages/knip/src/util/cli-arguments.ts @@ -13,6 +13,7 @@ Options: --directory [dir] Run process from a different directory (default: cwd) --cache Enable caching --cache-location Change cache location (default: node_modules/.cache/knip) + --watch Watch mode --no-gitignore Don't use .gitignore --include Report only provided issue type(s), can be comma-separated or repeated (1) --exclude Exclude provided issue type(s) from report, can be comma-separated or repeated (1) @@ -91,6 +92,7 @@ try { trace: { type: 'boolean' }, tsConfig: { type: 'string', short: 't' }, version: { type: 'boolean', short: 'V' }, + watch: { type: 'boolean' }, workspace: { type: 'string', short: 'W' }, }, }); diff --git a/packages/knip/test/helpers/baseArguments.ts b/packages/knip/test/helpers/baseArguments.ts index 2f87b0794..ef2c05a85 100644 --- a/packages/knip/test/helpers/baseArguments.ts +++ b/packages/knip/test/helpers/baseArguments.ts @@ -9,6 +9,8 @@ const baseArguments = { isIncludeEntryExports: false, isIncludeLibs: false, isIsolateWorkspaces: false, + isDebug: false, + isWatch: false, isFix: false, tags: [[], []], fixTypes: [],