UNPKG

knip

Version:

Find unused files, dependencies and exports in your TypeScript and JavaScript projects

368 lines (367 loc) 17.1 kB
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')); }); } }