knip
Version:
Find and fix unused dependencies, exports and files in your TypeScript and JavaScript projects
371 lines (370 loc) • 17.7 kB
JavaScript
import picomatch from 'picomatch';
import { DEFAULT_EXTENSIONS, ROOT_WORKSPACE_NAME } from "./constants.js";
import { pluginNames } from "./types/PluginNames.js";
import { arrayify, compact, partition } from "./util/array.js";
import { createWorkspaceGraph } from "./util/create-workspace-graph.js";
import { isDirectory, isFile } from "./util/fs.js";
import { _dirGlob, removeProductionSuffix } from "./util/glob.js";
import { graphSequencer } from "./util/graph-sequencer.js";
import mapWorkspaces from "./util/map-workspaces.js";
import { join, relative } from "./util/path.js";
import { normalizePluginConfig } from "./util/plugin.js";
import { toRegexOrString } from "./util/regex.js";
import { ELLIPSIS } from "./util/string.js";
import { byPathDepth } from "./util/workspace.js";
import { createWorkspaceFilePathFilter } from "./util/workspace-file-filter.js";
import { selectWorkspaces } from "./util/workspace-selectors.js";
const defaultBaseFilenamePattern = '{index,cli,main}';
export const isDefaultPattern = (type, id) => {
if (type === 'project')
return id.startsWith('**/*.{js,mjs,cjs,jsx,ts,tsx,mts,cts');
return (id.startsWith('{index,cli,main}.{js,mjs,cjs,jsx,ts,tsx,mts,cts') ||
id.startsWith('src/{index,cli,main}.{js,mjs,cjs,jsx,ts,tsx,mts,cts'));
};
const getDefaultWorkspaceConfig = (extensions = []) => {
const exts = [...DEFAULT_EXTENSIONS, ...extensions].map(ext => ext.slice(1)).join(',');
return {
entry: [`${defaultBaseFilenamePattern}.{${exts}}!`, `src/${defaultBaseFilenamePattern}.{${exts}}!`],
project: [`**/*.{${exts}}!`],
};
};
const isPluginName = (name) => pluginNames.includes(name);
const defaultConfig = {
ignore: [],
ignoreBinaries: [],
ignoreDependencies: [],
ignoreFiles: [],
ignoreIssues: {},
ignoreMembers: [],
ignoreUnresolved: [],
ignoreWorkspaces: [],
ignoreExportsUsedInFile: false,
isIncludeEntryExports: false,
syncCompilers: new Map(),
asyncCompilers: new Map(),
rootPluginConfigs: {},
};
export class ConfigurationChief {
cwd;
rawConfig;
isProduction;
isStrict;
isIncludeEntryExports;
config;
workspace;
selectedWorkspaces;
workspaceFilePathFilter = () => true;
workspaces;
ignoredWorkspacePatterns = [];
workspacePackages = new Map();
workspacesByPkgName = new Map();
workspacesByDir = new Map();
additionalWorkspaceNames = new Set();
availableWorkspaceNames = [];
availableWorkspacePkgNames = new Set();
availableWorkspaceDirs = [];
workspaceGraph = new Map();
constructor(options) {
this.cwd = options.cwd;
this.isProduction = options.isProduction;
this.isStrict = options.isStrict;
this.isIncludeEntryExports = options.isIncludeEntryExports;
this.workspace = options.workspace;
this.workspaces = options.workspaces;
this.rawConfig = options.parsedConfig;
this.config = this.normalize(options.parsedConfig ?? {});
}
getConfigurationHints() {
const hints = [];
if (this.rawConfig) {
if (this.workspacePackages.size > 1) {
const entry = arrayify(this.rawConfig.entry);
if (entry.length > 0) {
const identifier = `[${entry[0]}${entry.length > 1 ? `, ${ELLIPSIS}` : ''}]`;
hints.push({ type: 'entry-top-level', identifier });
}
const project = arrayify(this.rawConfig.project);
if (project.length > 0) {
const identifier = `[${project[0]}${project.length > 1 ? `, ${ELLIPSIS}` : ''}]`;
hints.push({ type: 'project-top-level', identifier });
}
}
}
return hints;
}
normalize(rawConfig) {
const ignore = arrayify(rawConfig.ignore ?? defaultConfig.ignore);
const ignoreFiles = arrayify(rawConfig.ignoreFiles ?? defaultConfig.ignoreFiles);
const ignoreBinaries = rawConfig.ignoreBinaries ?? [];
const ignoreDependencies = rawConfig.ignoreDependencies ?? [];
const ignoreMembers = rawConfig.ignoreMembers ?? [];
const ignoreUnresolved = rawConfig.ignoreUnresolved ?? [];
const ignoreExportsUsedInFile = rawConfig.ignoreExportsUsedInFile ?? false;
const ignoreIssues = rawConfig.ignoreIssues ?? {};
const ignoreWorkspaces = rawConfig.ignoreWorkspaces ?? defaultConfig.ignoreWorkspaces;
const isIncludeEntryExports = rawConfig.includeEntryExports ?? this.isIncludeEntryExports;
const { syncCompilers, asyncCompilers } = rawConfig;
const rootPluginConfigs = {};
for (const [pluginName, pluginConfig] of Object.entries(rawConfig)) {
if (isPluginName(pluginName)) {
rootPluginConfigs[pluginName] = normalizePluginConfig(pluginConfig);
}
}
return {
ignore,
ignoreFiles,
ignoreBinaries,
ignoreDependencies,
ignoreMembers,
ignoreUnresolved,
ignoreExportsUsedInFile,
ignoreIssues,
ignoreWorkspaces,
isIncludeEntryExports,
syncCompilers: new Map(Object.entries(syncCompilers ?? {})),
asyncCompilers: new Map(Object.entries(asyncCompilers ?? {})),
rootPluginConfigs,
};
}
async getWorkspaces() {
this.ignoredWorkspacePatterns = this.getIgnoredWorkspacePatterns();
this.additionalWorkspaceNames = await this.getAdditionalWorkspaceNames();
const workspaceNames = compact([...this.getListedWorkspaces(), ...this.additionalWorkspaceNames]);
const [packages, wsPkgNames] = await mapWorkspaces(this.cwd, [...workspaceNames, '.']);
this.workspacePackages = packages;
this.availableWorkspaceNames = this.getAvailableWorkspaceNames(packages.keys());
this.availableWorkspacePkgNames = wsPkgNames;
this.availableWorkspaceDirs = this.availableWorkspaceNames
.sort(byPathDepth)
.reverse()
.map(dir => join(this.cwd, dir));
this.workspaceGraph = createWorkspaceGraph(this.cwd, this.availableWorkspaceNames, wsPkgNames, packages);
this.selectedWorkspaces = this.getSelectedWorkspaces();
this.workspaceFilePathFilter = createWorkspaceFilePathFilter(this.cwd, this.selectedWorkspaces, this.availableWorkspaceNames);
const includedWorkspaces = this.getIncludedWorkspaces();
for (const workspace of includedWorkspaces) {
this.workspacesByPkgName.set(workspace.pkgName, workspace);
this.workspacesByDir.set(workspace.dir, workspace);
}
const sorted = graphSequencer(this.workspaceGraph, Array.from(this.workspacesByDir.keys()).filter(dir => this.workspaceGraph.has(dir)));
const [root, rest] = partition(sorted.chunks.flat(), dir => dir === this.cwd);
return [...root, ...rest.reverse()].map(dir => this.workspacesByDir.get(dir));
}
getListedWorkspaces() {
return this.workspaces.map(pattern => pattern.replace(/(?<=!?)\.\//, ''));
}
getIgnoredWorkspaces() {
const ignoreWorkspaces = this.config.ignoreWorkspaces;
if (this.isProduction)
return ignoreWorkspaces.map(removeProductionSuffix);
return ignoreWorkspaces.filter(pattern => !pattern.endsWith('!'));
}
getIgnoredWorkspacePatterns() {
const ignoredWorkspacesManifest = this.getListedWorkspaces()
.filter(name => name.startsWith('!'))
.map(name => name.replace(/^!/, ''));
return [...ignoredWorkspacesManifest, ...this.getIgnoredWorkspaces()];
}
getConfiguredWorkspaceKeys() {
const initialWorkspaces = this.rawConfig?.workspaces
? Object.keys(this.rawConfig.workspaces)
: [ROOT_WORKSPACE_NAME];
const ignoreWorkspaces = this.getIgnoredWorkspaces();
return initialWorkspaces.filter(workspaceName => !ignoreWorkspaces.includes(workspaceName));
}
async getAdditionalWorkspaceNames() {
const workspaceKeys = this.getConfiguredWorkspaceKeys();
const patterns = workspaceKeys.filter(key => key.includes('*'));
const dirs = workspaceKeys.filter(key => !key.includes('*'));
const globbedDirs = await _dirGlob({ patterns, cwd: this.cwd });
return new Set([...dirs, ...globbedDirs].filter(name => name !== ROOT_WORKSPACE_NAME &&
!this.workspacePackages.has(name) &&
!picomatch.isMatch(name, this.ignoredWorkspacePatterns)));
}
getAvailableWorkspaceNames(names) {
const availableWorkspaceNames = [];
const [ignore, patterns] = partition(this.ignoredWorkspacePatterns, pattern => pattern.startsWith('!'));
const ignoreSliced = ignore.map(pattern => pattern.slice(1));
for (const name of names) {
if (!picomatch.isMatch(name, patterns, { ignore: ignoreSliced })) {
availableWorkspaceNames.push(name);
}
}
return availableWorkspaceNames;
}
getIncludedWorkspaces() {
const selectedWorkspaces = this.selectedWorkspaces;
const isAncestor = (name, ancestor) => ancestor !== name && (ancestor === ROOT_WORKSPACE_NAME || name.startsWith(`${ancestor}/`));
const getAncestors = (name) => this.availableWorkspaceNames.filter(a => isAncestor(name, a));
const workspaceNames = selectedWorkspaces
? Array.from(selectedWorkspaces).flatMap(name => [...getAncestors(name), name])
: this.availableWorkspaceNames;
const ws = new Set();
if (selectedWorkspaces && this.isStrict) {
for (const name of selectedWorkspaces)
ws.add(name);
}
else if (selectedWorkspaces) {
const graph = this.workspaceGraph;
if (graph) {
const seen = new Set();
const initialWorkspaces = new Set(workspaceNames.map(name => join(this.cwd, name)));
const workspaceDirsWithDependents = new Set(initialWorkspaces);
const addDependents = (dir) => {
seen.add(dir);
const dirs = graph.get(dir);
if (!dirs || dirs.size === 0)
return;
for (const d of dirs)
if (initialWorkspaces.has(d)) {
workspaceDirsWithDependents.add(dir);
break;
}
for (const dir of dirs)
if (!seen.has(dir))
addDependents(dir);
};
for (const dir of this.availableWorkspaceDirs)
addDependents(dir);
for (const dir of workspaceDirsWithDependents)
ws.add(relative(this.cwd, dir));
}
}
else {
for (const name of workspaceNames)
ws.add(name);
}
return Array.from(ws)
.sort(byPathDepth)
.map((name) => {
const dir = join(this.cwd, name);
const pkg = this.workspacePackages.get(name);
const pkgName = pkg?.pkgName ?? `KNIP_ADDED_${name}`;
const manifestPath = pkg?.manifestPath ?? join(dir, 'package.json');
const manifestStr = pkg?.manifestStr ?? '';
const workspaceConfig = this.getWorkspaceConfig(name);
const ignoreMembers = workspaceConfig.ignoreMembers?.map(toRegexOrString) ?? [];
return {
name,
pkgName,
dir,
config: this.getConfigForWorkspace(name),
ancestors: getAncestors(name),
manifestPath,
manifestStr,
ignoreMembers,
};
});
}
getManifestForWorkspace(name) {
return this.workspacePackages.get(name)?.manifest;
}
getDescendentWorkspaces(name) {
const prefix = `${name}/`;
return this.availableWorkspaceNames.filter(workspaceName => workspaceName !== name && (name === ROOT_WORKSPACE_NAME || workspaceName.startsWith(prefix)));
}
getIgnoredWorkspacesFor(name) {
return this.ignoredWorkspacePatterns
.filter(workspaceName => workspaceName !== name)
.filter(workspaceName => name === ROOT_WORKSPACE_NAME || workspaceName.startsWith(name));
}
createIgnoredWorkspaceMatcher(name, dir) {
const ignoredWorkspaces = this.getIgnoredWorkspacesFor(name);
if (ignoredWorkspaces.length === 0)
return () => false;
return (filePath) => {
const relativePath = filePath.startsWith(dir) ? filePath.slice(dir.length + 1) : filePath;
return picomatch.isMatch(relativePath, ignoredWorkspaces);
};
}
getNegatedWorkspacePatterns(name) {
const descendentWorkspaces = this.getDescendentWorkspaces(name);
const matchName = new RegExp(`^${name}/`);
const ignoredWorkspaces = this.getIgnoredWorkspacesFor(name);
const endMatch = /\/\*{1,2}$|\/$|$/;
return [...ignoredWorkspaces, ...descendentWorkspaces]
.map(workspaceName => workspaceName.replace(matchName, ''))
.map(workspaceName => `!${workspaceName.replace(endMatch, '/**')}`);
}
getConfigKeyForWorkspace(workspaceName) {
return this.getConfiguredWorkspaceKeys()
.sort(byPathDepth)
.reverse()
.find(pattern => picomatch.isMatch(workspaceName, pattern));
}
getSelectedWorkspaces() {
if (!this.workspace)
return;
const workspaceSelectors = Array.isArray(this.workspace) ? this.workspace : [this.workspace];
return selectWorkspaces(workspaceSelectors, this.cwd, this.workspacePackages, this.availableWorkspaceNames);
}
getWorkspaceConfig(workspaceName) {
const key = this.getConfigKeyForWorkspace(workspaceName);
const workspaces = this.rawConfig?.workspaces ?? {};
return ((key
? key === ROOT_WORKSPACE_NAME && !(ROOT_WORKSPACE_NAME in workspaces)
? this.rawConfig
: workspaces[key]
: {}) ?? {});
}
getIgnores(workspaceName) {
const workspaceConfig = this.getWorkspaceConfig(workspaceName);
const ignoreBinaries = workspaceConfig.ignoreBinaries ?? [];
const ignoreDependencies = workspaceConfig.ignoreDependencies ?? [];
const ignoreUnresolved = workspaceConfig.ignoreUnresolved ?? [];
if (workspaceName === ROOT_WORKSPACE_NAME) {
const { ignoreBinaries: rootIgnoreBinaries, ignoreDependencies: rootIgnoreDependencies, ignoreUnresolved: rootIgnoreUnresolved, } = this.rawConfig ?? {};
return {
ignoreBinaries: compact([...ignoreBinaries, ...(rootIgnoreBinaries ?? [])]),
ignoreDependencies: compact([...ignoreDependencies, ...(rootIgnoreDependencies ?? [])]),
ignoreUnresolved: compact([...ignoreUnresolved, ...(rootIgnoreUnresolved ?? [])]),
};
}
return { ignoreBinaries, ignoreDependencies, ignoreUnresolved };
}
getConfigForWorkspace(workspaceName, extensions) {
const baseConfig = getDefaultWorkspaceConfig(extensions);
const workspaceConfig = this.getWorkspaceConfig(workspaceName);
const entry = workspaceConfig.entry ? arrayify(workspaceConfig.entry) : baseConfig.entry;
const project = workspaceConfig.project ? arrayify(workspaceConfig.project) : baseConfig.project;
const paths = workspaceConfig.paths ?? {};
const ignore = arrayify(workspaceConfig.ignore);
const ignoreFiles = arrayify(workspaceConfig.ignoreFiles);
const isIncludeEntryExports = workspaceConfig.includeEntryExports ?? this.config.isIncludeEntryExports;
const plugins = {};
for (const [pluginName, pluginConfig] of Object.entries(this.config.rootPluginConfigs)) {
if (typeof pluginConfig !== 'undefined')
plugins[pluginName] = pluginConfig;
}
for (const [pluginName, pluginConfig] of Object.entries(workspaceConfig)) {
if (isPluginName(pluginName)) {
plugins[pluginName] = normalizePluginConfig(pluginConfig);
}
}
return { entry, project, paths, ignore, ignoreFiles, isIncludeEntryExports, ...plugins };
}
findWorkspaceByFilePath(filePath) {
const workspaceDir = this.availableWorkspaceDirs.find(workspaceDir => filePath.startsWith(`${workspaceDir}/`));
if (!workspaceDir)
return undefined;
return this.workspacesByDir.get(workspaceDir);
}
getUnusedIgnoredWorkspaces() {
const ignoredWorkspaceNames = this.config.ignoreWorkspaces.map(removeProductionSuffix);
const matchesWorkspace = (pattern) => {
for (const name of this.workspacePackages.keys())
if (picomatch.isMatch(name, pattern))
return true;
for (const name of this.additionalWorkspaceNames)
if (picomatch.isMatch(name, pattern))
return true;
return false;
};
return ignoredWorkspaceNames
.filter(ignoredWorkspaceName => !matchesWorkspace(ignoredWorkspaceName))
.filter(ignoredWorkspaceName => {
const dir = join(this.cwd, ignoredWorkspaceName);
return !isDirectory(dir) || isFile(dir, 'package.json');
});
}
}