knip
Version:
Find unused files, dependencies and exports in your TypeScript and JavaScript projects
546 lines (545 loc) • 30 kB
JavaScript
import { watch } from 'node:fs';
import { ConfigurationChief } from './ConfigurationChief.js';
import { ConsoleStreamer } from './ConsoleStreamer.js';
import { DependencyDeputy } from './DependencyDeputy.js';
import { IssueCollector } from './IssueCollector.js';
import { IssueFixer } from './IssueFixer.js';
import { PrincipalFactory } from './PrincipalFactory.js';
import { WorkspaceWorker } from './WorkspaceWorker.js';
import { _getInputsFromScripts } from './binaries/index.js';
import { getCompilerExtensions, getIncludedCompilers } from './compilers/index.js';
import { debugLog, debugLogArray, debugLogObject } from './util/debug.js';
import { getOrCreateFileNode, updateImportMap } from './util/dependency-graph.js';
import { getReferencedInputsHandler } from './util/get-referenced-inputs.js';
import { getGitIgnoredHandler } from './util/glob-core.js';
import { _glob, negate } from './util/glob.js';
import { getHasStrictlyNsReferences, getType } from './util/has-strictly-ns-references.js';
import { isConfigPattern, isEntry, isProductionEntry, toProductionEntry } from './util/input.js';
import { getIsIdentifierReferencedHandler } from './util/is-identifier-referenced.js';
import { getPackageNameFromModuleSpecifier } from './util/modules.js';
import { getEntryPathsFromManifest } from './util/package-json.js';
import { dirname, isAbsolute, join, relative } from './util/path.js';
import { findMatch } from './util/regex.js';
import { getShouldIgnoreHandler, getShouldIgnoreTagHandler } from './util/tag.js';
import { augmentWorkspace, getToSourcePathHandler } from './util/to-source-path.js';
import { createAndPrintTrace, printTrace } from './util/trace.js';
import { loadTSConfig } from './util/tsconfig-loader.js';
import { getWatchHandler } from './util/watch.js';
export const main = async (unresolvedConfiguration) => {
const { cacheLocation, cwd, excludedIssueTypes, fixTypes, gitignore, includedIssueTypes, isCache, isDebug, isDependenciesShorthand, isExportsShorthand, isFilesShorthand, isFix, isHideConfigHints, isIncludeEntryExports, isIncludeLibs, isIsolateWorkspaces, isProduction, isRemoveFiles, isShowProgress, isStrict, isWatch, tags, tsConfigFile, workspace, } = unresolvedConfiguration;
debugLogObject('*', 'Unresolved configuration (from CLI arguments)', unresolvedConfiguration);
const chief = new ConfigurationChief({ cwd, isProduction, isStrict, isIncludeEntryExports, workspace });
const deputy = new DependencyDeputy({ isProduction, isStrict });
const factory = new PrincipalFactory();
const streamer = new ConsoleStreamer({ isEnabled: isShowProgress });
streamer.cast('Reading workspace configuration(s)...');
await chief.init();
const workspaces = chief.getIncludedWorkspaces();
const report = chief.getIncludedIssueTypes({
includedIssueTypes,
excludedIssueTypes,
isDependenciesShorthand,
isExportsShorthand,
isFilesShorthand,
});
const rules = chief.getRules();
const filters = chief.getFilters();
const fixer = new IssueFixer({ isEnabled: isFix, cwd, fixTypes, isRemoveFiles });
debugLogObject('*', 'Included issue types', report);
const isReportDependencies = report.dependencies || report.unlisted || report.unresolved;
const isReportValues = report.exports || report.nsExports || report.classMembers;
const isReportTypes = report.types || report.nsTypes || report.enumMembers;
const isReportClassMembers = report.classMembers;
const isSkipLibs = !(isIncludeLibs || isReportClassMembers);
const isShowConfigHints = !workspace && !isProduction && !isHideConfigHints;
const collector = new IssueCollector({ cwd, rules, filters });
const allConfigFilePaths = new Set();
const enabledPluginsStore = new Map();
const o = () => workspaces.map(w => ({ pkgName: w.pkgName, name: w.name, config: w.config, ancestors: w.ancestors }));
debugLogObject('*', 'Included workspaces', () => workspaces.map(w => w.pkgName));
debugLogObject('*', 'Included workspace configs', o);
const isGitIgnored = await getGitIgnoredHandler({ cwd, gitignore });
const toSourceFilePath = getToSourcePathHandler(chief);
const getReferencedInternalFilePath = getReferencedInputsHandler(collector, deputy, chief, isGitIgnored);
const shouldIgnore = getShouldIgnoreHandler(isProduction);
const shouldIgnoreTags = getShouldIgnoreTagHandler(tags);
for (const workspace of workspaces) {
const { name, dir, manifestPath } = workspace;
const manifest = chief.getManifestForWorkspace(name);
if (!manifest)
continue;
const { ignoreBinaries, ignoreDependencies } = chief.getIgnores(name);
deputy.addWorkspace({ name, cwd, dir, manifestPath, manifest, ignoreBinaries, ignoreDependencies });
}
for (const workspace of workspaces) {
const { name, dir, ancestors, pkgName } = workspace;
streamer.cast(`Analyzing workspace ${name}...`);
const manifest = chief.getManifestForWorkspace(name);
if (!manifest) {
continue;
}
const dependencies = deputy.getDependencies(name);
const compilers = getIncludedCompilers(chief.config.syncCompilers, chief.config.asyncCompilers, dependencies);
const extensions = getCompilerExtensions(compilers);
const config = chief.getConfigForWorkspace(name, extensions);
const tsConfigFilePath = join(dir, tsConfigFile ?? 'tsconfig.json');
const { isFile, compilerOptions, definitionPaths } = await loadTSConfig(tsConfigFilePath);
if (isFile)
augmentWorkspace(workspace, dir, compilerOptions);
const worker = new WorkspaceWorker({
name,
dir,
cwd,
config,
manifest,
dependencies,
getReferencedInternalFilePath: (input) => getReferencedInternalFilePath(input, workspace),
isProduction,
isStrict,
rootIgnore: chief.config.ignore,
negatedWorkspacePatterns: chief.getNegatedWorkspacePatterns(name),
ignoredWorkspacePatterns: chief.getIgnoredWorkspacesFor(name),
enabledPluginsInAncestors: ancestors.flatMap(ancestor => enabledPluginsStore.get(ancestor) ?? []),
isCache,
cacheLocation,
allConfigFilePaths,
});
await worker.init();
const deps = new Set();
if (definitionPaths.length > 0) {
debugLogArray(name, 'Definition paths', definitionPaths);
for (const id of definitionPaths)
deps.add(toProductionEntry(id, { containingFilePath: tsConfigFilePath }));
}
const ignore = worker.getIgnorePatterns();
const sharedGlobOptions = { cwd, dir, gitignore };
collector.addIgnorePatterns(ignore.map(pattern => join(cwd, pattern)));
const entryPathsFromManifest = await getEntryPathsFromManifest(manifest, { ...sharedGlobOptions, ignore });
for (const id of entryPathsFromManifest.map(id => toProductionEntry(id)))
deps.add(id);
const dependenciesFromPlugins = await worker.findDependenciesByPlugins();
for (const id of dependenciesFromPlugins)
deps.add(id);
enabledPluginsStore.set(name, worker.enabledPlugins);
const principal = factory.createPrincipal({
cwd: dir,
paths: config.paths,
isFile,
compilerOptions,
compilers,
pkgName,
isIsolateWorkspaces,
isSkipLibs,
isWatch,
toSourceFilePath,
isCache,
cacheLocation,
});
const entryFilePatterns = new Set();
const productionEntryFilePatterns = new Set();
for (const dependency of deps) {
const s = dependency.specifier;
if (isEntry(dependency)) {
entryFilePatterns.add(isAbsolute(s) ? relative(dir, s) : s);
}
else if (isProductionEntry(dependency)) {
productionEntryFilePatterns.add(isAbsolute(s) ? relative(dir, s) : s);
}
else if (!isConfigPattern(dependency)) {
const ws = (dependency.containingFilePath && chief.findWorkspaceByFilePath(dependency.containingFilePath)) || workspace;
const specifierFilePath = getReferencedInternalFilePath(dependency, ws);
if (specifierFilePath)
principal.addEntryPath(specifierFilePath, { skipExportsAnalysis: true });
}
}
if (isProduction) {
const negatedEntryPatterns = Array.from(entryFilePatterns).map(negate);
{
const label = 'entry';
const patterns = worker.getProductionEntryFilePatterns(negatedEntryPatterns);
const workspaceEntryPaths = await _glob({ ...sharedGlobOptions, patterns, gitignore: false, label });
principal.addEntryPaths(workspaceEntryPaths);
}
{
const label = 'production plugin entry';
const patterns = Array.from(productionEntryFilePatterns);
const pluginWorkspaceEntryPaths = await _glob({ ...sharedGlobOptions, patterns, label });
principal.addEntryPaths(pluginWorkspaceEntryPaths, { skipExportsAnalysis: true });
}
{
const label = 'project';
const patterns = worker.getProductionProjectFilePatterns(negatedEntryPatterns);
const workspaceProjectPaths = await _glob({ ...sharedGlobOptions, patterns, label });
for (const projectPath of workspaceProjectPaths)
principal.addProjectPath(projectPath);
}
}
else {
{
const label = 'entry';
const patterns = worker.getEntryFilePatterns();
const workspaceEntryPaths = await _glob({ ...sharedGlobOptions, patterns, gitignore: false, label });
principal.addEntryPaths(workspaceEntryPaths);
}
{
const label = 'project';
const patterns = worker.getProjectFilePatterns([...productionEntryFilePatterns]);
const workspaceProjectPaths = await _glob({ ...sharedGlobOptions, patterns, label });
for (const projectPath of workspaceProjectPaths)
principal.addProjectPath(projectPath);
}
{
const label = 'plugin entry';
const patterns = worker.getPluginEntryFilePatterns([...entryFilePatterns, ...productionEntryFilePatterns]);
const pluginWorkspaceEntryPaths = await _glob({ ...sharedGlobOptions, patterns, label });
principal.addEntryPaths(pluginWorkspaceEntryPaths, { skipExportsAnalysis: true });
}
{
const label = 'plugin project';
const patterns = worker.getPluginProjectFilePatterns();
const pluginWorkspaceProjectPaths = await _glob({ ...sharedGlobOptions, patterns, label });
for (const projectPath of pluginWorkspaceProjectPaths)
principal.addProjectPath(projectPath);
}
{
const label = 'plugin configuration';
const patterns = worker.getPluginConfigPatterns();
const configurationEntryPaths = await _glob({ ...sharedGlobOptions, patterns, label });
principal.addEntryPaths(configurationEntryPaths, { skipExportsAnalysis: true });
}
}
if (chief.resolvedConfigFilePath) {
principal.addEntryPath(chief.resolvedConfigFilePath, { skipExportsAnalysis: true });
}
worker.onDispose();
}
const principals = factory.getPrincipals();
debugLog('*', `Created ${principals.length} programs for ${workspaces.length} workspaces`);
const graph = new Map();
const analyzedFiles = new Set();
const unreferencedFiles = new Set();
const entryPaths = new Set();
const isIdentifierReferenced = getIsIdentifierReferencedHandler(graph, entryPaths);
const isPackageNameInternalWorkspace = (packageName) => chief.availableWorkspacePkgNames.has(packageName);
const getPrincipalByFilePath = (filePath) => {
const workspace = chief.findWorkspaceByFilePath(filePath);
if (workspace)
return factory.getPrincipalByPackageName(workspace.pkgName);
};
const analyzeSourceFile = (filePath, principal) => {
if (!isWatch && analyzedFiles.has(filePath))
return;
analyzedFiles.add(filePath);
const workspace = chief.findWorkspaceByFilePath(filePath);
if (workspace) {
const { imports, exports, duplicates, scripts, traceRefs } = principal.analyzeSourceFile(filePath, {
skipTypeOnly: isStrict,
isFixExports: fixer.isEnabled && fixer.isFixUnusedExports,
isFixTypes: fixer.isEnabled && fixer.isFixUnusedTypes,
ignoreExportsUsedInFile: chief.config.ignoreExportsUsedInFile,
isReportClassMembers,
tags,
}, isGitIgnored, isPackageNameInternalWorkspace, getPrincipalByFilePath);
const file = getOrCreateFileNode(graph, filePath);
file.imports = imports;
file.exports = exports;
file.duplicates = duplicates;
file.scripts = scripts;
file.traceRefs = traceRefs;
updateImportMap(file, imports.internal, graph);
file.internalImportCache = imports.internal;
graph.set(filePath, file);
if (scripts && scripts.size > 0) {
const dependencies = deputy.getDependencies(workspace.name);
const manifestScriptNames = new Set(Object.keys(chief.getManifestForWorkspace(workspace.name)?.scripts ?? {}));
const rootCwd = cwd;
const options = {
cwd: dirname(filePath),
rootCwd,
containingFilePath: filePath,
dependencies,
manifestScriptNames,
};
const inputs = _getInputsFromScripts(scripts, options);
for (const input of inputs) {
input.containingFilePath = filePath;
input.dir = cwd;
const specifierFilePath = getReferencedInternalFilePath(input, workspace);
if (specifierFilePath)
analyzeSourceFile(specifierFilePath, principal);
}
}
}
};
for (let i = 0; i < principals.length; ++i) {
const principal = principals[i];
if (!principal)
continue;
principal.init();
streamer.cast('Running async compilers...');
await principal.runAsyncCompilers();
streamer.cast('Analyzing source files...');
let size = principal.entryPaths.size;
let round = 0;
do {
size = principal.entryPaths.size;
const resolvedFiles = principal.getUsedResolvedFiles();
const files = resolvedFiles.filter(filePath => !analyzedFiles.has(filePath));
debugLogArray('*', `Analyzing used resolved files [P${i + 1}/${++round}]`, files);
for (const filePath of files)
analyzeSourceFile(filePath, principal);
} while (size !== principal.entryPaths.size);
for (const filePath of principal.getUnreferencedFiles())
unreferencedFiles.add(filePath);
for (const filePath of principal.entryPaths)
entryPaths.add(filePath);
principal.reconcileCache(graph);
if (!isIsolateWorkspaces && isSkipLibs && !isWatch) {
factory.deletePrincipal(principal);
principals[i] = undefined;
}
}
if (isIsolateWorkspaces) {
for (const principal of principals) {
if (principal)
factory.deletePrincipal(principal);
}
principals.length = 0;
}
const ignoreExportsUsedInFile = chief.config.ignoreExportsUsedInFile;
const isExportedItemReferenced = (exportedItem) => exportedItem.refs[1] ||
(exportedItem.refs[0] > 0 &&
(typeof ignoreExportsUsedInFile === 'object'
? exportedItem.type !== 'unknown' && !!ignoreExportsUsedInFile[exportedItem.type]
: ignoreExportsUsedInFile));
const collectUnusedExports = async () => {
if (isReportValues || isReportTypes) {
streamer.cast('Connecting the dots...');
for (const [filePath, file] of graph.entries()) {
const exportItems = file.exports;
if (!exportItems || exportItems.size === 0)
continue;
const workspace = chief.findWorkspaceByFilePath(filePath);
if (workspace) {
const { isIncludeEntryExports } = workspace.config;
const principal = factory.getPrincipalByPackageName(workspace.pkgName);
const isEntry = entryPaths.has(filePath);
if (!isIncludeEntryExports && isEntry) {
createAndPrintTrace(filePath, { isEntry });
continue;
}
const importsForExport = file.imported;
for (const [identifier, exportedItem] of exportItems.entries()) {
if (!isFix && exportedItem.isReExport)
continue;
if (shouldIgnore(exportedItem.jsDocTags))
continue;
const isIgnored = shouldIgnoreTags(exportedItem.jsDocTags);
if (importsForExport) {
const { isReferenced, reExportingEntryFile, traceNode } = isIdentifierReferenced(filePath, identifier, isIncludeEntryExports);
if ((isReferenced || exportedItem.refs[1]) && isIgnored) {
for (const tagName of exportedItem.jsDocTags) {
if (tags[1].includes(tagName.replace(/^\@/, ''))) {
collector.addTagHint({ type: 'tag', filePath, identifier, tagName });
}
}
}
if (isIgnored)
continue;
if (reExportingEntryFile) {
if (!isIncludeEntryExports) {
createAndPrintTrace(filePath, { identifier, isEntry, hasRef: isReferenced });
continue;
}
const reExportedItem = graph.get(reExportingEntryFile)?.exports.get(identifier);
if (reExportedItem && shouldIgnore(reExportedItem.jsDocTags))
continue;
}
if (traceNode)
printTrace(traceNode, filePath, identifier);
if (isReferenced) {
if (report.enumMembers && exportedItem.type === 'enum') {
if (importsForExport.refs.has(identifier))
continue;
for (const member of exportedItem.members) {
if (findMatch(workspace.ignoreMembers, member.identifier))
continue;
if (shouldIgnore(member.jsDocTags))
continue;
if (member.refs[0] === 0) {
const id = `${identifier}.${member.identifier}`;
const { isReferenced } = isIdentifierReferenced(filePath, id, true);
const isIgnored = shouldIgnoreTags(member.jsDocTags);
if (!isReferenced) {
if (isIgnored)
continue;
const isIssueAdded = collector.addIssue({
type: 'enumMembers',
filePath,
workspace: workspace.name,
symbol: member.identifier,
parentSymbol: identifier,
pos: member.pos,
line: member.line,
col: member.col,
});
if (isFix && isIssueAdded && member.fix)
fixer.addUnusedTypeNode(filePath, [member.fix]);
}
else if (isIgnored) {
for (const tagName of exportedItem.jsDocTags) {
if (tags[1].includes(tagName.replace(/^\@/, ''))) {
collector.addTagHint({ type: 'tag', filePath, identifier: id, tagName });
}
}
}
}
}
}
if (principal && isReportClassMembers && exportedItem.type === 'class') {
const members = exportedItem.members.filter(member => !(findMatch(workspace.ignoreMembers, member.identifier) || shouldIgnore(member.jsDocTags)));
for (const member of principal.findUnusedMembers(filePath, members)) {
if (shouldIgnoreTags(member.jsDocTags)) {
const identifier = `${exportedItem.identifier}.${member.identifier}`;
for (const tagName of exportedItem.jsDocTags) {
if (tags[1].includes(tagName.replace(/^\@/, ''))) {
collector.addTagHint({ type: 'tag', filePath, identifier, tagName });
}
}
continue;
}
const isIssueAdded = collector.addIssue({
type: 'classMembers',
filePath,
workspace: workspace.name,
symbol: member.identifier,
parentSymbol: exportedItem.identifier,
pos: member.pos,
line: member.line,
col: member.col,
});
if (isFix && isIssueAdded && member.fix)
fixer.addUnusedTypeNode(filePath, [member.fix]);
}
}
continue;
}
}
const [hasStrictlyNsRefs, namespace] = getHasStrictlyNsReferences(graph, importsForExport, identifier);
const isType = ['enum', 'type', 'interface'].includes(exportedItem.type);
if (hasStrictlyNsRefs && ((!report.nsTypes && isType) || !(report.nsExports || isType)))
continue;
if (!isExportedItemReferenced(exportedItem)) {
if (isIgnored)
continue;
if (!isSkipLibs && principal?.hasExternalReferences(filePath, exportedItem))
continue;
const type = getType(hasStrictlyNsRefs, isType);
const isIssueAdded = collector.addIssue({
type,
filePath,
workspace: workspace.name,
symbol: identifier,
symbolType: exportedItem.type,
parentSymbol: namespace,
pos: exportedItem.pos,
line: exportedItem.line,
col: exportedItem.col,
});
if (isFix && isIssueAdded) {
if (isType)
fixer.addUnusedTypeNode(filePath, exportedItem.fixes);
else
fixer.addUnusedExportNode(filePath, exportedItem.fixes);
}
}
}
}
}
}
for (const [filePath, file] of graph.entries()) {
const ws = chief.findWorkspaceByFilePath(filePath);
if (ws) {
if (file.duplicates) {
for (const symbols of file.duplicates) {
if (symbols.length > 1) {
const symbol = symbols.map(s => s.symbol).join('|');
collector.addIssue({ type: 'duplicates', filePath, workspace: ws.name, symbol, symbols });
}
}
}
if (file.imports?.external) {
for (const specifier of file.imports.external) {
const packageName = getPackageNameFromModuleSpecifier(specifier);
const isHandled = packageName && deputy.maybeAddReferencedExternalDependency(ws, packageName);
if (!isHandled)
collector.addIssue({
type: 'unlisted',
filePath,
workspace: ws.name,
symbol: packageName ?? specifier,
specifier,
});
}
}
if (file.imports?.unresolved) {
for (const unresolvedImport of file.imports.unresolved) {
const { specifier, pos, line, col } = unresolvedImport;
collector.addIssue({ type: 'unresolved', filePath, workspace: ws.name, symbol: specifier, pos, line, col });
}
}
}
}
const unusedFiles = [...unreferencedFiles].filter(filePath => !analyzedFiles.has(filePath));
collector.addFilesIssues(unusedFiles);
collector.addFileCounts({ processed: analyzedFiles.size, unused: unusedFiles.length });
if (isReportDependencies) {
const { dependencyIssues, devDependencyIssues, optionalPeerDependencyIssues } = deputy.settleDependencyIssues();
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);
deputy.removeIgnoredIssues(collector.getIssues());
if (isShowConfigHints) {
const configurationHints = deputy.getConfigurationHints();
for (const hint of configurationHints)
collector.addConfigurationHint(hint);
}
}
if (isShowConfigHints) {
const unusedIgnoredWorkspaces = chief.getUnusedIgnoredWorkspaces();
for (const identifier of unusedIgnoredWorkspaces) {
collector.addConfigurationHint({ type: 'ignoreWorkspaces', identifier });
}
}
};
await collectUnusedExports();
const { issues, counters, tagHints, configurationHints } = collector.getIssues();
if (isWatch) {
const isIgnored = (filePath) => filePath.startsWith(cacheLocation) || filePath.includes('/.git/') || isGitIgnored(filePath);
const watchHandler = await getWatchHandler({
analyzedFiles,
analyzeSourceFile,
chief,
collector,
collectUnusedExports,
cwd,
factory,
graph,
isDebug,
isIgnored,
report,
streamer,
unreferencedFiles,
});
watch('.', { recursive: true }, watchHandler);
}
if (isFix)
await fixer.fixIssues(issues);
if (!isWatch)
streamer.clear();
return { report, issues, counters, rules, tagHints, configurationHints };
};