knip
Version:
Find unused files, dependencies and exports in your TypeScript and JavaScript projects
368 lines (367 loc) • 17.1 kB
JavaScript
import picomatch from 'picomatch';
import { ConfigurationValidator } from './ConfigurationValidator.js';
import { partitionCompilers } from './compilers/index.js';
import { DEFAULT_EXTENSIONS, KNIP_CONFIG_LOCATIONS, ROOT_WORKSPACE_NAME } from './constants.js';
import { defaultRules } from './issues/initializers.js';
import { pluginNames } from './types/PluginNames.js';
import { arrayify, compact } from './util/array.js';
import parsedArgValues from './util/cli-arguments.js';
import { createWorkspaceGraph } from './util/create-workspace-graph.js';
import { ConfigurationError } from './util/errors.js';
import { findFile, isDirectory, isFile, loadJSON } from './util/fs.js';
import { getIncludedIssueTypes } from './util/get-included-issue-types.js';
import { _dirGlob } from './util/glob.js';
import { _load } from './util/loader.js';
import mapWorkspaces from './util/map-workspaces.js';
import { getKeysByValue } from './util/object.js';
import { join, relative, resolve } from './util/path.js';
import { normalizePluginConfig } from './util/plugin.js';
import { toRegexOrString } from './util/regex.js';
import { unwrapFunction } from './util/unwrap-function.js';
import { byPathDepth } from './util/workspace.js';
const { config: rawConfigArg } = parsedArgValues;
const getDefaultWorkspaceConfig = (extensions) => {
const exts = [...DEFAULT_EXTENSIONS, ...(extensions ?? [])].map(ext => ext.slice(1)).join(',');
return {
entry: [`{index,cli,main}.{${exts}}!`, `src/{index,cli,main}.{${exts}}!`],
project: [`**/*.{${exts}}!`],
};
};
const isPluginName = (name) => pluginNames.includes(name);
const defaultConfig = {
rules: defaultRules,
include: [],
exclude: [],
ignore: [],
ignoreBinaries: [],
ignoreDependencies: [],
ignoreMembers: [],
ignoreExportsUsedInFile: false,
ignoreWorkspaces: [],
isIncludeEntryExports: false,
syncCompilers: new Map(),
asyncCompilers: new Map(),
rootPluginConfigs: {},
};
export class ConfigurationChief {
cwd;
isProduction = false;
isStrict = false;
isIncludeEntryExports = false;
config;
workspace;
manifestPath;
manifest;
ignoredWorkspacePatterns = [];
workspacePackages = new Map();
workspacesByPkgName = new Map();
workspacesByName = new Map();
additionalWorkspaceNames = new Set();
availableWorkspaceNames = [];
availableWorkspacePkgNames = new Set();
availableWorkspaceDirs = [];
workspaceGraph;
includedWorkspaces = [];
resolvedConfigFilePath;
rawConfig;
constructor({ cwd, isProduction, isStrict, isIncludeEntryExports, workspace }) {
this.cwd = cwd;
this.isProduction = isProduction;
this.isStrict = isStrict;
this.isIncludeEntryExports = isIncludeEntryExports;
this.config = defaultConfig;
this.workspace = workspace;
}
async init() {
const manifestPath = findFile(this.cwd, 'package.json');
const manifest = manifestPath && (await loadJSON(manifestPath));
if (!(manifestPath && manifest)) {
throw new ConfigurationError('Unable to find package.json');
}
this.manifestPath = manifestPath;
this.manifest = manifest;
const pnpmWorkspacesPath = findFile(this.cwd, 'pnpm-workspace.yaml');
const pnpmWorkspaces = pnpmWorkspacesPath && (await _load(pnpmWorkspacesPath));
if (this.manifest && !this.manifest.workspaces && pnpmWorkspaces) {
this.manifest.workspaces = pnpmWorkspaces;
}
for (const configPath of rawConfigArg ? [rawConfigArg] : KNIP_CONFIG_LOCATIONS) {
this.resolvedConfigFilePath = findFile(this.cwd, configPath);
if (this.resolvedConfigFilePath)
break;
}
if (rawConfigArg && !this.resolvedConfigFilePath && !manifest.knip) {
throw new ConfigurationError(`Unable to find ${rawConfigArg} or package.json#knip`);
}
this.rawConfig = this.resolvedConfigFilePath
? await this.loadResolvedConfigurationFile(this.resolvedConfigFilePath)
: manifest.knip;
const parsedConfig = this.rawConfig ? ConfigurationValidator.parse(partitionCompilers(this.rawConfig)) : {};
this.config = this.normalize(parsedConfig);
await this.setWorkspaces();
}
async loadResolvedConfigurationFile(configPath) {
const loadedValue = await _load(configPath);
try {
return await unwrapFunction(loadedValue);
}
catch (_error) {
throw new ConfigurationError(`Error running the function from ${configPath}`);
}
}
getRules() {
return this.config.rules;
}
getFilters() {
if (this.workspaceGraph && this.workspace)
return { dir: join(this.cwd, this.workspace) };
return {};
}
normalize(rawConfig) {
const rules = { ...defaultRules, ...rawConfig.rules };
const include = rawConfig.include ?? defaultConfig.include;
const exclude = rawConfig.exclude ?? defaultConfig.exclude;
const ignore = arrayify(rawConfig.ignore ?? defaultConfig.ignore);
const ignoreBinaries = rawConfig.ignoreBinaries ?? [];
const ignoreDependencies = rawConfig.ignoreDependencies ?? [];
const ignoreMembers = rawConfig.ignoreMembers ?? [];
const ignoreExportsUsedInFile = rawConfig.ignoreExportsUsedInFile ?? false;
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 {
rules,
include,
exclude,
ignore,
ignoreBinaries,
ignoreDependencies,
ignoreMembers,
ignoreExportsUsedInFile,
ignoreWorkspaces,
isIncludeEntryExports,
syncCompilers: new Map(Object.entries(syncCompilers ?? {})),
asyncCompilers: new Map(Object.entries(asyncCompilers ?? {})),
rootPluginConfigs,
};
}
async setWorkspaces() {
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.includedWorkspaces = this.setIncludedWorkspaces();
for (const workspace of this.includedWorkspaces) {
this.workspacesByPkgName.set(workspace.pkgName, workspace);
this.workspacesByName.set(workspace.name, workspace);
}
}
getListedWorkspaces() {
const workspaces = this.manifest?.workspaces
? Array.isArray(this.manifest.workspaces)
? this.manifest.workspaces
: (this.manifest.workspaces.packages ?? [])
: [];
return workspaces.map(pattern => pattern.replace(/(?<=!?)\.\//, ''));
}
getIgnoredWorkspacePatterns() {
const ignoredWorkspacesManifest = this.getListedWorkspaces()
.filter(name => name.startsWith('!'))
.map(name => name.replace(/^!/, ''));
return [...ignoredWorkspacesManifest, ...this.config.ignoreWorkspaces];
}
getConfiguredWorkspaceKeys() {
const initialWorkspaces = this.rawConfig?.workspaces
? Object.keys(this.rawConfig.workspaces)
: [ROOT_WORKSPACE_NAME];
const ignoreWorkspaces = this.rawConfig?.ignoreWorkspaces ?? defaultConfig.ignoreWorkspaces;
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) {
return [...names, ...this.additionalWorkspaceNames].filter(name => !picomatch.isMatch(name, this.ignoredWorkspacePatterns));
}
setIncludedWorkspaces() {
if (this.workspace) {
const dir = resolve(this.workspace);
if (!isDirectory(dir))
throw new ConfigurationError('Workspace is not a directory');
if (!isFile(join(dir, 'package.json')))
throw new ConfigurationError('Unable to find package.json in workspace');
}
const getAncestors = (name) => (ancestors, ancestorName) => {
if (name === ancestorName)
return ancestors;
if (ancestorName === ROOT_WORKSPACE_NAME || name.startsWith(`${ancestorName}/`))
ancestors.push(ancestorName);
return ancestors;
};
const workspaceNames = this.workspace
? [...this.availableWorkspaceNames.reduce(getAncestors(this.workspace), []), this.workspace]
: this.availableWorkspaceNames;
const ws = new Set();
if (this.workspace && this.isStrict) {
ws.add(this.workspace);
}
else if (this.workspace) {
const graph = this.workspaceGraph;
if (graph) {
const seen = new Set();
const initialWorkspaces = workspaceNames.map(name => join(this.cwd, name));
const workspaceDirsWithDependents = new Set(initialWorkspaces);
const addDependents = (dir) => {
seen.add(dir);
if (!graph[dir] || graph[dir].size === 0)
return;
const dirs = graph[dir];
if (initialWorkspaces.some(dir => dirs.has(dir)))
workspaceDirsWithDependents.add(dir);
for (const dir of dirs)
if (!seen.has(dir))
addDependents(dir);
};
this.availableWorkspaceDirs.forEach(addDependents);
for (const dir of workspaceDirsWithDependents)
ws.add(relative(this.cwd, dir) || ROOT_WORKSPACE_NAME);
}
}
else {
for (const name of workspaceNames)
ws.add(name);
}
return Array.from(ws)
.sort(byPathDepth)
.map((name) => {
const dir = join(this.cwd, name);
const pkgName = this.workspacePackages.get(name)?.pkgName ?? `KNIP_ADDED_${name}`;
const workspaceConfig = this.getWorkspaceConfig(name);
const ignoreMembers = arrayify(workspaceConfig.ignoreMembers).map(toRegexOrString);
return {
name,
pkgName,
dir,
config: this.getConfigForWorkspace(name),
ancestors: this.availableWorkspaceNames.reduce(getAncestors(name), []),
manifestPath: join(dir, 'package.json'),
ignoreMembers,
};
});
}
getManifestForWorkspace(name) {
return this.workspacePackages.get(name)?.manifest;
}
getIncludedWorkspaces() {
return this.includedWorkspaces;
}
getDescendentWorkspaces(name) {
return this.availableWorkspaceNames
.filter(workspaceName => workspaceName !== name)
.filter(workspaceName => name === ROOT_WORKSPACE_NAME || workspaceName.startsWith(`${name}/`));
}
getIgnoredWorkspacesFor(name) {
return this.ignoredWorkspacePatterns
.filter(workspaceName => workspaceName !== name)
.filter(workspaceName => name === ROOT_WORKSPACE_NAME || workspaceName.startsWith(name));
}
getNegatedWorkspacePatterns(name) {
const descendentWorkspaces = this.getDescendentWorkspaces(name);
const matchName = new RegExp(`^${name}/`);
const ignoredWorkspaces = this.getIgnoredWorkspacesFor(name);
return [...ignoredWorkspaces, ...descendentWorkspaces]
.map(workspaceName => workspaceName.replace(matchName, ''))
.map(workspaceName => `!${workspaceName}`);
}
getConfigKeyForWorkspace(workspaceName) {
return this.getConfiguredWorkspaceKeys()
.sort(byPathDepth)
.reverse()
.find(pattern => picomatch.isMatch(workspaceName, pattern));
}
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 = arrayify(workspaceConfig.ignoreBinaries);
const ignoreDependencies = arrayify(workspaceConfig.ignoreDependencies);
if (workspaceName === ROOT_WORKSPACE_NAME) {
const { ignoreBinaries: rootIgnoreBinaries, ignoreDependencies: rootIgnoreDependencies } = this.rawConfig ?? {};
return {
ignoreBinaries: compact([...ignoreBinaries, ...(rootIgnoreBinaries ?? [])]),
ignoreDependencies: compact([...ignoreDependencies, ...(rootIgnoreDependencies ?? [])]),
};
}
return { ignoreBinaries, ignoreDependencies };
}
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 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, isIncludeEntryExports, ...plugins };
}
getIncludedIssueTypes(cliArgs) {
const excludesFromRules = getKeysByValue(this.config.rules, 'off');
const config = {
include: this.config.include ?? [],
exclude: [...excludesFromRules, ...this.config.exclude],
isProduction: this.isProduction,
};
return getIncludedIssueTypes(cliArgs, config);
}
findWorkspaceByFilePath(filePath) {
const workspaceDir = this.availableWorkspaceDirs.find(workspaceDir => filePath.startsWith(`${workspaceDir}/`));
return this.includedWorkspaces.find(workspace => workspace.dir === workspaceDir);
}
getUnusedIgnoredWorkspaces() {
const ignoredWorkspaceNames = this.config.ignoreWorkspaces;
const workspaceNames = [...this.workspacePackages.keys(), ...this.additionalWorkspaceNames];
return ignoredWorkspaceNames
.filter(ignoredWorkspaceName => !workspaceNames.some(name => picomatch.isMatch(name, ignoredWorkspaceName)))
.filter(ignoredWorkspaceName => {
const dir = join(this.cwd, ignoredWorkspaceName);
return !isDirectory(dir) || isFile(join(dir, 'package.json'));
});
}
}