From fe84779d81ad7fa3e3e4326bbd0127e6e1b7fcc6 Mon Sep 17 00:00:00 2001 From: Lars Kappert Date: Tue, 10 Feb 2026 17:30:02 +0100 Subject: [PATCH] Support bulk suppressions --- .../docs/features/bulk-suppressions.md | 140 +++++++ .../docs/src/content/docs/reference/cli.md | 35 ++ .../suppressions-workspaces/package.json | 7 + .../workspace-a/index.ts | 2 + .../workspace-a/module.ts | 2 + .../workspace-a/package.json | 3 + .../workspace-b/index.ts | 2 + .../workspace-b/module.ts | 2 + .../workspace-b/package.json | 3 + .../suppressions/.knip-suppressions.json | 22 ++ packages/knip/fixtures/suppressions/index.ts | 2 + packages/knip/fixtures/suppressions/module.ts | 3 + .../knip/fixtures/suppressions/package.json | 7 + packages/knip/fixtures/suppressions/unused.ts | 1 + packages/knip/src/IssueCollector.ts | 4 +- packages/knip/src/cli.ts | 24 ++ packages/knip/src/constants.ts | 2 + packages/knip/src/reporters/symbols.ts | 6 + packages/knip/src/types/issues.ts | 2 + packages/knip/src/types/suppressions.ts | 19 + packages/knip/src/util/cli-arguments.ts | 18 +- packages/knip/src/util/create-options.ts | 5 + packages/knip/src/util/suppressions.ts | 243 ++++++++++++ .../knip/test/suppressions-workspaces.test.ts | 57 +++ packages/knip/test/suppressions.test.ts | 355 ++++++++++++++++++ packages/knip/test/util/suppressions.test.ts | 212 +++++++++++ 26 files changed, 1176 insertions(+), 2 deletions(-) create mode 100644 packages/docs/src/content/docs/features/bulk-suppressions.md create mode 100644 packages/knip/fixtures/suppressions-workspaces/package.json create mode 100644 packages/knip/fixtures/suppressions-workspaces/workspace-a/index.ts create mode 100644 packages/knip/fixtures/suppressions-workspaces/workspace-a/module.ts create mode 100644 packages/knip/fixtures/suppressions-workspaces/workspace-a/package.json create mode 100644 packages/knip/fixtures/suppressions-workspaces/workspace-b/index.ts create mode 100644 packages/knip/fixtures/suppressions-workspaces/workspace-b/module.ts create mode 100644 packages/knip/fixtures/suppressions-workspaces/workspace-b/package.json create mode 100644 packages/knip/fixtures/suppressions/.knip-suppressions.json create mode 100644 packages/knip/fixtures/suppressions/index.ts create mode 100644 packages/knip/fixtures/suppressions/module.ts create mode 100644 packages/knip/fixtures/suppressions/package.json create mode 100644 packages/knip/fixtures/suppressions/unused.ts create mode 100644 packages/knip/src/types/suppressions.ts create mode 100644 packages/knip/src/util/suppressions.ts create mode 100644 packages/knip/test/suppressions-workspaces.test.ts create mode 100644 packages/knip/test/suppressions.test.ts create mode 100644 packages/knip/test/util/suppressions.test.ts diff --git a/packages/docs/src/content/docs/features/bulk-suppressions.md b/packages/docs/src/content/docs/features/bulk-suppressions.md new file mode 100644 index 000000000..cf1b0080b --- /dev/null +++ b/packages/docs/src/content/docs/features/bulk-suppressions.md @@ -0,0 +1,140 @@ +--- +title: Bulk Suppressions +--- + +Knip supports a suppression system to ignore reported issues. This is useful +when introducing Knip to a large, existing codebase, or when you want to +temporarily ignore specific issues. + +Suppressions are not yet supported in [production][1]/[strict][2] mode. + +## Generating suppressions + +To suppress all currently reported issues, run: + +```sh +knip --suppress-all +``` + +This creates a `.knip-suppressions.json` file in the project root. This file +acts as a baseline: it snapshots usage issues so you can focus on new issues, or +burn down the existing ones at your own pace. + +Flags: + +- Use `--suppress-until ` to add an expiry date (`YYYY-MM-DD`) to new + suppressions. +- Use `--suppressions-location ` for a custom file path. +- Use existing [scope flags][3] like `--include`, `--exports` and `--workspace` + to filter suppressions. + +## Managing suppressions + +Stale suppressions are pruned automatically on every `knip` run. When you fix an +issue (or delete the code), the corresponding entry will be automatically +removed from the file. + +### Tackling suppressed issues + +To reveal a subset of suppressed issues, combine `--no-suppressions` with one or +more [scope flags][3] like `--include`, `--exports` or `--workspace`: + +```sh +knip --no-suppressions --exports +``` + +This shows all suppressed export issues so you can fix them incrementally. After +fixing, just run `knip` to update the suppressions file automatically. + +### Expiry + +Use the `--suppress-until` argument, or manually add an `until` field to any +suppression in the JSON file: + +```json +"src/feature-flagged.ts": { + "exports": { + "deprecatedHelper": {"until":"2026-02-16"} + } +} +``` + +After this date, the suppression is ignored, and Knip will report the issue +again. This might help with planning, temporary workarounds and migration +processes. + +## CI + +To ensure that new issues are caught (not suppressed) and the suppressions file +is up-to-date (no unused entries): + +```sh +knip --check-suppressions +``` + +This exits non-zero if the suppression file has changed (i.e. if suppressions +were auto-pruned or added). This enforces suppression file updates committed +along with fixed issues. + +## Suppressions vs. JSDoc tags + +The suppressions file is intended for bulk-ignoring existing issues when +introducing Knip to a codebase. For individual cases where you want to document +_why_ something is kept, prefer JSDoc tags like `@lintignore`, `@internal` or +`@public` directly in the code: + +```ts +/** @lintignore Exported but unused for reasons */ +export function formatDate() {} +``` + +Tags live next to the code, carry context naturally, and don't rely on an +external file. See [JSDoc Tags][4] for details. + +That said, additional fields in the JSON file are preserved. + +## Suppressions vs. ignore patterns + +Use `ignore*` items for false positives (i.e. when Knip is wrong), use +suppressions for actual issues you want to fix later. + +Remember that [ignore][5] patterns are nearly always a bad idea. They might hurt +performance and hide issues that you do want to know about. [Exclude the file +from analysis][6], use a more specific `ignore*` pattern to get rid of a false +positive, or suppress a specific issue temporarily. + +## Suppressions file + +The `.knip-suppressions.json` file is human-readable and git-friendly. Sorted +keys and one line per item: + +```json title=".knip-suppressions.json" +{ + "version": 1, + "suppressions": { + "packages/ui/package.json": { + "dependencies": { + "lodash": {} + } + }, + "src/old-module.ts": { + "files": { + "src/old-module.ts": {"until":"2026-02-16"} + } + }, + "src/utils/helpers.ts": { + "exports": { + "formatDate": {}, + "parseQuery": {"until":"2026-02-16"} + } + } + } +} +``` + +[1]: ./production-mode.md +[2]: ./production-mode.md#strict-mode +[3]: ../reference/cli#scope +[4]: ../reference/jsdoc-tsdoc-tags.md +[5]: ../reference/configuration.md#ignore +[6]: ../guides/configuring-project-files.md diff --git a/packages/docs/src/content/docs/reference/cli.md b/packages/docs/src/content/docs/reference/cli.md index 7cfa66ea7..3366ee695 100644 --- a/packages/docs/src/content/docs/reference/cli.md +++ b/packages/docs/src/content/docs/reference/cli.md @@ -366,6 +366,41 @@ The default exit codes: | `1` | Knip ran successfully, but there is at least one lint issue | | `2` | Knip did not run successfully due to bad input or internal error | +## Suppressions + +### `--suppress-all` + +Generate a `.knip-suppressions.json` file to suppress all currently reported +issues. + +### `--suppress-type [type]` + +Suppress only a specific issue type (e.g. `exports` or `dependencies`). Can be +combined with `--suppress-all` to refresh only one type. + +### `--suppress-until [date]` + +Snapshot this date into the suppressions file. Format: `YYYY-MM-DD`. Knip +ignores the suppression after this date. + +### `--suppressions-location` + +Path to the suppressions file. Default: `.knip-suppressions.json`. + +### `--check-suppressions` + +Exit with an error code if the suppressions file is modified (e.g. by +auto-pruning). Useful in CI to ensure the file is up-to-date. + +### `--prune-suppressions` + +Explicitly remove unused entries from the suppressions file. (Note: Knip +automatically prunes unused suppressions when running with `--fix`). + +### `--no-suppressions` + +Run Knip ignoring any existing suppressions file. + ## Troubleshooting ### `--debug` diff --git a/packages/knip/fixtures/suppressions-workspaces/package.json b/packages/knip/fixtures/suppressions-workspaces/package.json new file mode 100644 index 000000000..c3f3ddebd --- /dev/null +++ b/packages/knip/fixtures/suppressions-workspaces/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fixtures/suppressions-workspaces", + "workspaces": [ + "workspace-a", + "workspace-b" + ] +} diff --git a/packages/knip/fixtures/suppressions-workspaces/workspace-a/index.ts b/packages/knip/fixtures/suppressions-workspaces/workspace-a/index.ts new file mode 100644 index 000000000..a563a1c1e --- /dev/null +++ b/packages/knip/fixtures/suppressions-workspaces/workspace-a/index.ts @@ -0,0 +1,2 @@ +import { used } from './module'; +used(); diff --git a/packages/knip/fixtures/suppressions-workspaces/workspace-a/module.ts b/packages/knip/fixtures/suppressions-workspaces/workspace-a/module.ts new file mode 100644 index 000000000..cdb9cdacb --- /dev/null +++ b/packages/knip/fixtures/suppressions-workspaces/workspace-a/module.ts @@ -0,0 +1,2 @@ +export const used = () => 'used'; +export const unusedA = 'unused in workspace-a'; diff --git a/packages/knip/fixtures/suppressions-workspaces/workspace-a/package.json b/packages/knip/fixtures/suppressions-workspaces/workspace-a/package.json new file mode 100644 index 000000000..de92c1dd9 --- /dev/null +++ b/packages/knip/fixtures/suppressions-workspaces/workspace-a/package.json @@ -0,0 +1,3 @@ +{ + "name": "@fixtures/suppressions-workspaces__a" +} diff --git a/packages/knip/fixtures/suppressions-workspaces/workspace-b/index.ts b/packages/knip/fixtures/suppressions-workspaces/workspace-b/index.ts new file mode 100644 index 000000000..a563a1c1e --- /dev/null +++ b/packages/knip/fixtures/suppressions-workspaces/workspace-b/index.ts @@ -0,0 +1,2 @@ +import { used } from './module'; +used(); diff --git a/packages/knip/fixtures/suppressions-workspaces/workspace-b/module.ts b/packages/knip/fixtures/suppressions-workspaces/workspace-b/module.ts new file mode 100644 index 000000000..82486c144 --- /dev/null +++ b/packages/knip/fixtures/suppressions-workspaces/workspace-b/module.ts @@ -0,0 +1,2 @@ +export const used = () => 'used'; +export const unusedB = 'unused in workspace-b'; diff --git a/packages/knip/fixtures/suppressions-workspaces/workspace-b/package.json b/packages/knip/fixtures/suppressions-workspaces/workspace-b/package.json new file mode 100644 index 000000000..93cfcf55b --- /dev/null +++ b/packages/knip/fixtures/suppressions-workspaces/workspace-b/package.json @@ -0,0 +1,3 @@ +{ + "name": "@fixtures/suppressions-workspaces__b" +} diff --git a/packages/knip/fixtures/suppressions/.knip-suppressions.json b/packages/knip/fixtures/suppressions/.knip-suppressions.json new file mode 100644 index 000000000..0d65e0c01 --- /dev/null +++ b/packages/knip/fixtures/suppressions/.knip-suppressions.json @@ -0,0 +1,22 @@ +{ + "version": 1, + "suppressions": { + "module.ts": { + "exports": { + "anotherUnused": {}, + "unusedExport": {} + } + }, + "package.json": { + "dependencies": { + "unused-pkg": {}, + "used-pkg": {} + } + }, + "unused.ts": { + "files": { + "unused.ts": {} + } + } + } +} diff --git a/packages/knip/fixtures/suppressions/index.ts b/packages/knip/fixtures/suppressions/index.ts new file mode 100644 index 000000000..a563a1c1e --- /dev/null +++ b/packages/knip/fixtures/suppressions/index.ts @@ -0,0 +1,2 @@ +import { used } from './module'; +used(); diff --git a/packages/knip/fixtures/suppressions/module.ts b/packages/knip/fixtures/suppressions/module.ts new file mode 100644 index 000000000..a453bbe61 --- /dev/null +++ b/packages/knip/fixtures/suppressions/module.ts @@ -0,0 +1,3 @@ +export const used = () => 'used'; +export const unusedExport = () => 'unused'; +export const anotherUnused = 'also unused'; diff --git a/packages/knip/fixtures/suppressions/package.json b/packages/knip/fixtures/suppressions/package.json new file mode 100644 index 000000000..30841fda4 --- /dev/null +++ b/packages/knip/fixtures/suppressions/package.json @@ -0,0 +1,7 @@ +{ + "name": "@fixtures/suppressions", + "dependencies": { + "used-pkg": "*", + "unused-pkg": "*" + } +} diff --git a/packages/knip/fixtures/suppressions/unused.ts b/packages/knip/fixtures/suppressions/unused.ts new file mode 100644 index 000000000..46673a72d --- /dev/null +++ b/packages/knip/fixtures/suppressions/unused.ts @@ -0,0 +1 @@ +export const orphan = 'this file is unused'; diff --git a/packages/knip/src/IssueCollector.ts b/packages/knip/src/IssueCollector.ts index d7802e37f..ca0174185 100644 --- a/packages/knip/src/IssueCollector.ts +++ b/packages/knip/src/IssueCollector.ts @@ -137,7 +137,9 @@ export class IssueCollector { this.issues.files.add(filePath); const symbol = relative(this.cwd, filePath); // @ts-expect-error TODO Fix up in next major - this.issues._files[symbol] = [{ type: 'files', filePath, symbol, severity: this.rules.files }]; + this.issues._files[symbol] = { + [symbol]: { type: 'files', filePath, symbol, severity: this.rules.files, fixes: [] }, + }; this.counters.files++; this.counters.processed++; diff --git a/packages/knip/src/cli.ts b/packages/knip/src/cli.ts index a07b6d30f..f1d292163 100644 --- a/packages/knip/src/cli.ts +++ b/packages/knip/src/cli.ts @@ -16,6 +16,7 @@ import { logError, logWarning } from './util/log.js'; import { perfObserver } from './util/Performance.js'; import { runPreprocessors, runReporters } from './util/reporter.js'; import { prettyMilliseconds } from './util/string.js'; +import { _handleSuppressions } from './util/suppressions.js'; import { version } from './version.js'; let args: ReturnType = {}; @@ -59,6 +60,27 @@ const main = async () => { // These modes have their own reporting mechanism if (options.isWatch || options.isTrace) return; + let suppressedCount = 0; + let expiredCount = 0; + + if (!options.isProduction) { + const suppressionResult = await _handleSuppressions(issues, counters, options); + + if (suppressionResult.action === 'generated') { + console.log(suppressionResult.message); + process.exit(0); + } + + if (suppressionResult.action === 'applied') { + suppressedCount = suppressionResult.suppressedCount; + expiredCount = suppressionResult.expiredCount; + if (suppressionResult.isChanged && options.checkSuppressions) { + console.log('Suppressions file has been updated. Please commit the changes.'); + process.exit(1); + } + } + } + const initialData: ReporterOptions = { report: options.includedIssueTypes, issues, @@ -77,6 +99,8 @@ const main = async () => { options: args['reporter-options'] ?? '', preprocessorOptions: args['preprocessor-options'] ?? '', selectedWorkspaces, + suppressedCount, + expiredCount, }; const finalData = await runPreprocessors(args.preprocessor ?? [], initialData); diff --git a/packages/knip/src/constants.ts b/packages/knip/src/constants.ts index e343dc6ab..87f4d71c3 100644 --- a/packages/knip/src/constants.ts +++ b/packages/knip/src/constants.ts @@ -15,6 +15,8 @@ export const KNIP_CONFIG_LOCATIONS = [ 'knip.config.js', ]; +export const DEFAULT_SUPPRESSIONS_FILE = '.knip-suppressions.json'; + export const DEFAULT_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts']; export const IS_DTS = /\.d\.(c|m)?ts$/; diff --git a/packages/knip/src/reporters/symbols.ts b/packages/knip/src/reporters/symbols.ts index 866fe9207..f5d530a71 100644 --- a/packages/knip/src/reporters/symbols.ts +++ b/packages/knip/src/reporters/symbols.ts @@ -40,4 +40,10 @@ export default (options: ReporterOptions) => { ) { console.log('✂️ Excellent, Knip found no issues.'); } + + if (isShowProgress && options.suppressedCount && options.suppressedCount > 0) { + const parts = [`${options.suppressedCount} suppressed`]; + if (options.expiredCount && options.expiredCount > 0) parts.push(`${options.expiredCount} expired`); + console.log(dim(`(${parts.join(', ')})`)); + } }; diff --git a/packages/knip/src/types/issues.ts b/packages/knip/src/types/issues.ts index 5d553e7c4..80c772d62 100644 --- a/packages/knip/src/types/issues.ts +++ b/packages/knip/src/types/issues.ts @@ -78,6 +78,8 @@ export type ReporterOptions = { selectedWorkspaces: string[] | undefined; configFilePath: string | undefined; maxShowIssues?: number; + suppressedCount?: number; + expiredCount?: number; }; export type Reporter = (options: ReporterOptions) => void; diff --git a/packages/knip/src/types/suppressions.ts b/packages/knip/src/types/suppressions.ts new file mode 100644 index 000000000..4fd6d09c3 --- /dev/null +++ b/packages/knip/src/types/suppressions.ts @@ -0,0 +1,19 @@ +import type { IssueType } from './issues.js'; + +export interface SuppressionMeta { + until?: string; +} + +export type SuppressionEntry = Record; + +export type SuppressionsByType = Partial>; + +export interface Suppressions { + version: 1; + suppressions: Record; +} + +export interface ApplyResult { + suppressedCount: number; + expiredCount: number; +} diff --git a/packages/knip/src/util/cli-arguments.ts b/packages/knip/src/util/cli-arguments.ts index 0448bc813..731d86462 100644 --- a/packages/knip/src/util/cli-arguments.ts +++ b/packages/knip/src/util/cli-arguments.ts @@ -15,7 +15,7 @@ Options: Mode --cache Enable caching - --cache-location Change cache location (default: node_modules/.cache/knip) + --cache-location [dir] Change cache location (default: node_modules/.cache/knip) --include-entry-exports Include entry files when reporting unused exports --include-libs Include type definitions from external dependencies (default: false) --isolate-workspaces Isolate workspaces into separate programs @@ -40,6 +40,15 @@ Fix --allow-remove-files Allow Knip to remove files (with --fix) --format Format modified files after --fix using the local formatter +Suppressions + --suppress-all Generate suppressions file for all current issues + --suppress-type [type] Suppress only a specific issue type + --suppress-until [date] Set an expiry date (YYYY-MM-DD) on generated suppressions + --prune-suppressions Remove stale entries from suppressions file + --check-suppressions Fail if suppressions file needs updating + --suppressions-location Cache suppressions file location (default: .knip-suppressions.json) + --no-suppressions Ignore suppressions + Output --preprocessor Preprocess the results before providing it to the reporter(s), can be repeated --preprocessor-options Pass extra options to the preprocessor (as JSON string, see --reporter-options example) @@ -86,6 +95,7 @@ export default function parseCLIArgs() { options: { cache: { type: 'boolean' }, 'cache-location': { type: 'string' }, + 'check-suppressions': { type: 'boolean' }, config: { type: 'string', short: 'c' }, debug: { type: 'boolean', short: 'd' }, dependencies: { type: 'boolean' }, @@ -112,14 +122,20 @@ export default function parseCLIArgs() { 'no-exit-code': { type: 'boolean' }, 'no-gitignore': { type: 'boolean' }, 'no-progress': { type: 'boolean', short: 'n' }, + 'no-suppressions': { type: 'boolean' }, performance: { type: 'boolean' }, 'performance-fn': { type: 'string' }, production: { type: 'boolean' }, preprocessor: { type: 'string', multiple: true }, 'preprocessor-options': { type: 'string' }, reporter: { type: 'string', multiple: true }, + 'prune-suppressions': { type: 'boolean' }, 'reporter-options': { type: 'string' }, strict: { type: 'boolean' }, + 'suppress-all': { type: 'boolean' }, + 'suppress-type': { type: 'string' }, + 'suppress-until': { type: 'string' }, + 'suppressions-location': { type: 'string' }, trace: { type: 'boolean' }, 'trace-dependency': { type: 'string' }, 'trace-export': { type: 'string' }, diff --git a/packages/knip/src/util/create-options.ts b/packages/knip/src/util/create-options.ts index 94577cf3b..6d4e2a842 100644 --- a/packages/knip/src/util/create-options.ts +++ b/packages/knip/src/util/create-options.ts @@ -159,14 +159,19 @@ export const createOptions = async (options: CreateOptions) => { typeof process.stdout.cursorTo === 'function', isSkipLibs: !(isIncludeLibs || includedIssueTypes.classMembers), isStrict, + isSuppressAll: args['suppress-all'] ?? false, isTrace, isTreatConfigHintsAsErrors: args['treat-config-hints-as-errors'] ?? parsedConfig.treatConfigHintsAsErrors ?? false, isUseTscFiles: options.isUseTscFiles ?? args['use-tsconfig-files'] ?? (options.isSession && !configFilePath), isWatch: args.watch ?? options.isWatch ?? false, maxShowIssues: args['max-show-issues'] ? Number(args['max-show-issues']) : undefined, parsedConfig, + checkSuppressions: args['check-suppressions'] ?? false, + noSuppressions: args['no-suppressions'] ?? false, rules, tags, + suppressUntil: args['suppress-until'], + suppressionsFilePath: args['suppressions-location'] ? join(cwd, args['suppressions-location']) : undefined, traceDependency: args['trace-dependency'], traceExport: args['trace-export'], traceFile: args['trace-file'] ? toAbsolute(args['trace-file'], cwd) : undefined, diff --git a/packages/knip/src/util/suppressions.ts b/packages/knip/src/util/suppressions.ts new file mode 100644 index 000000000..b71835ffa --- /dev/null +++ b/packages/knip/src/util/suppressions.ts @@ -0,0 +1,243 @@ +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { DEFAULT_SUPPRESSIONS_FILE, ISSUE_TYPES } from '../constants.js'; +import type { Counters, IssueRecords, Issues, IssueType, Rules } from '../types/issues.js'; +import type { ApplyResult, SuppressionMeta, Suppressions, SuppressionsByType } from '../types/suppressions.js'; +import { timerify } from './Performance.js'; +import { join } from './path.js'; + +const isExpired = (until: string | undefined, now: string) => until !== undefined && until <= now; + +const getToday = () => new Date().toISOString().slice(0, 10); + +const getRecords = (issues: Issues, issueType: string) => + (issueType === 'files' ? issues._files : issues[issueType as IssueType]) as IssueRecords; + +const getDefaultSuppressionsFilePath = (cwd: string) => join(cwd, DEFAULT_SUPPRESSIONS_FILE); + +const loadSuppressions = async (filePath: string): Promise => { + if (!existsSync(filePath)) return undefined; + const contents = await readFile(filePath, 'utf8'); + return JSON.parse(contents); +}; + +const saveSuppressions = async (filePath: string, suppressions: Suppressions) => { + await writeFile(filePath, stringify(suppressions)); +}; + +/** @internal */ +export const generateSuppressions = (issues: Issues, until?: string, rules?: Rules): Suppressions => { + const entries: Record = {}; + const meta: SuppressionMeta = until ? { until } : {}; + + for (const issueType of ISSUE_TYPES) { + if (rules && rules[issueType] !== 'error') continue; + const records = getRecords(issues, issueType); + for (const [relPath, symbolMap] of Object.entries(records)) { + if (!entries[relPath]) entries[relPath] = {}; + const symbolNames = Object.keys(symbolMap); + if (symbolNames.length === 0) continue; + + const symbols: Record = {}; + for (const name of symbolNames) symbols[name] = { ...meta }; + entries[relPath][issueType] = symbols; + } + } + + return { version: 1, suppressions: entries }; +}; + +/** @internal */ +export const applySuppressions = (issues: Issues, bulk: Suppressions, rules?: Rules): ApplyResult => { + const now = getToday(); + let suppressedCount = 0; + let expiredCount = 0; + + for (const [key, byType] of Object.entries(bulk.suppressions)) { + for (const issueType of ISSUE_TYPES) { + const entry = byType[issueType]; + if (!entry) continue; + if (rules && rules[issueType] !== 'error') continue; + + const records = getRecords(issues, issueType); + let matchedAny = false; + for (const symbol of Object.keys(entry)) { + if (isExpired(entry[symbol].until, now)) { + expiredCount++; + continue; + } + if (records[key]?.[symbol]) { + delete records[key][symbol]; + suppressedCount++; + matchedAny = true; + } + } + + if (matchedAny && records[key] && Object.keys(records[key]).length === 0) delete records[key]; + } + } + + return { suppressedCount, expiredCount }; +}; + +/** @internal */ +export const pruneSuppressions = (issues: Issues, bulk: Suppressions): Suppressions => { + const now = getToday(); + const pruned: Record = {}; + + for (const [key, byType] of Object.entries(bulk.suppressions)) { + const prunedByType: SuppressionsByType = {}; + + for (const issueType of ISSUE_TYPES) { + const entry = byType[issueType]; + if (!entry) continue; + + const records = getRecords(issues, issueType); + if (!records[key]) continue; + + const remaining: Record = {}; + for (const [s, meta] of Object.entries(entry)) { + if (isExpired(meta.until, now)) continue; + if (records[key][s]) remaining[s] = meta; + } + if (Object.keys(remaining).length > 0) { + prunedByType[issueType] = remaining; + } + } + + if (Object.keys(prunedByType).length > 0) pruned[key] = prunedByType; + } + + return { version: 1, suppressions: pruned }; +}; + +/** @internal */ +export const mergeSuppressions = (existing: Suppressions, incoming: Suppressions): Suppressions => { + const merged: Record = {}; + + const allKeys = new Set([...Object.keys(existing.suppressions), ...Object.keys(incoming.suppressions)]); + + for (const key of allKeys) { + const existingByType: SuppressionsByType = existing.suppressions[key] ?? {}; + const incomingByType: SuppressionsByType = incoming.suppressions[key] ?? {}; + const mergedByType: SuppressionsByType = {}; + + for (const issueType of ISSUE_TYPES) { + const existingEntry = existingByType[issueType]; + const incomingEntry = incomingByType[issueType]; + + if (!incomingEntry) { + if (existingEntry) mergedByType[issueType] = existingEntry; + continue; + } + + if (!existingEntry) { + mergedByType[issueType] = incomingEntry; + continue; + } + + const symbols: Record = {}; + for (const [s, meta] of Object.entries(existingEntry)) symbols[s] = meta; + for (const [s, meta] of Object.entries(incomingEntry)) { + if (!symbols[s]) symbols[s] = meta; + } + mergedByType[issueType] = symbols; + } + + if (Object.keys(mergedByType).length > 0) merged[key] = mergedByType; + } + + return { version: 1, suppressions: merged }; +}; + +interface HandleSuppressionsOptions { + cwd: string; + isSuppressAll: boolean; + suppressUntil?: string; + suppressionsFilePath?: string; + noSuppressions: boolean; + rules: Rules; +} + +type HandleSuppressionsResult = + | { action: 'generated'; message: string } + | { + action: 'applied'; + suppressedCount: number; + expiredCount: number; + isChanged: boolean; + } + | { action: 'none' }; + +const handleSuppressions = async ( + issues: Issues, + counters: Counters, + options: HandleSuppressionsOptions +): Promise => { + const filePath = options.suppressionsFilePath ?? getDefaultSuppressionsFilePath(options.cwd); + + if (options.isSuppressAll) { + const newSuppressions = generateSuppressions(issues, options.suppressUntil, options.rules); + const existing = await loadSuppressions(filePath); + const merged = existing ? mergeSuppressions(existing, newSuppressions) : newSuppressions; + await saveSuppressions(filePath, merged); + return { + action: 'generated', + message: `Suppressions written to ${options.suppressionsFilePath ?? DEFAULT_SUPPRESSIONS_FILE}`, + }; + } + + if (options.noSuppressions) return { action: 'none' }; + + const existing = await loadSuppressions(filePath); + if (!existing) return { action: 'none' }; + + const updated = pruneSuppressions(issues, existing); + const result = applySuppressions(issues, existing, options.rules); + + for (const issueType of ISSUE_TYPES) { + const records = getRecords(issues, issueType); + let count = 0; + for (const rec of Object.values(records)) count += Object.keys(rec).length; + counters[issueType] = count; + } + + const isChanged = JSON.stringify(existing) !== JSON.stringify(updated); + if (isChanged) await saveSuppressions(filePath, updated); + + return { + action: 'applied', + suppressedCount: result.suppressedCount, + expiredCount: result.expiredCount, + isChanged, + }; +}; + +export const _handleSuppressions = timerify(handleSuppressions); + +/** @internal */ +export const stringify = (data: Suppressions) => { + const files = Object.keys(data.suppressions).sort(); + let out = '{\n "version": 1,\n "suppressions": {'; + for (let i = 0; i < files.length; i++) { + if (i) out += ','; + const byType = data.suppressions[files[i]]; + out += `\n ${JSON.stringify(files[i])}: {`; + let tj = 0; + for (const t of Object.keys(byType).sort() as IssueType[]) { + const entry = byType[t]; + if (!entry) continue; + if (tj++) out += ','; + out += `\n ${JSON.stringify(t)}: {`; + const symbols = Object.keys(entry).sort(); + for (let k = 0; k < symbols.length; k++) { + if (k) out += ','; + out += `\n ${JSON.stringify(symbols[k])}: ${JSON.stringify(entry[symbols[k]])}`; + } + out += '\n }'; + } + out += '\n }'; + } + out += '\n }\n}\n'; + return out; +}; diff --git a/packages/knip/test/suppressions-workspaces.test.ts b/packages/knip/test/suppressions-workspaces.test.ts new file mode 100644 index 000000000..64019212c --- /dev/null +++ b/packages/knip/test/suppressions-workspaces.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { main } from '../src/index.js'; +import type { Suppressions } from '../src/types/suppressions.js'; +import { generateSuppressions, mergeSuppressions, pruneSuppressions } from '../src/util/suppressions.js'; +import { createOptions } from './helpers/create-options.js'; +import { resolve } from './helpers/resolve.js'; + +const cwd = resolve('fixtures/suppressions-workspaces'); + +test('--suppress-all without workspace filter covers both workspaces', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const suppressions = generateSuppressions(issues); + + assert(suppressions.suppressions['workspace-a/module.ts']?.exports?.['unusedA']); + assert(suppressions.suppressions['workspace-b/module.ts']?.exports?.['unusedB']); +}); + +test('--suppress-all -W accumulates across workspaces', async () => { + const optionsA = await createOptions({ cwd, workspace: 'workspace-a' }); + const { issues: issuesA } = await main(optionsA); + const first = generateSuppressions(issuesA); + + const optionsB = await createOptions({ cwd, workspace: 'workspace-b' }); + const { issues: issuesB } = await main(optionsB); + const second = generateSuppressions(issuesB); + + const merged = mergeSuppressions(first, second); + + assert(merged.suppressions['workspace-a/module.ts']?.exports?.['unusedA']); + assert(merged.suppressions['workspace-b/module.ts']?.exports?.['unusedB']); +}); + +test('Pruning after fix in one workspace preserves the other', async () => { + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'workspace-a/module.ts': { + exports: { unusedA: {}, alreadyFixed: {} }, + }, + 'workspace-b/module.ts': { + exports: { unusedB: {} }, + }, + }, + }; + + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const pruned = pruneSuppressions(issues, suppressions); + + assert(pruned.suppressions['workspace-a/module.ts']?.exports?.['unusedA']); + assert(!pruned.suppressions['workspace-a/module.ts']?.exports?.['alreadyFixed']); + assert(pruned.suppressions['workspace-b/module.ts']?.exports?.['unusedB']); +}); diff --git a/packages/knip/test/suppressions.test.ts b/packages/knip/test/suppressions.test.ts new file mode 100644 index 000000000..16b638053 --- /dev/null +++ b/packages/knip/test/suppressions.test.ts @@ -0,0 +1,355 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { main } from '../src/index.js'; +import type { Rules } from '../src/types/issues.js'; +import type { Suppressions } from '../src/types/suppressions.js'; +import { defaultRules } from '../src/util/issue-initializers.js'; +import { + applySuppressions, + generateSuppressions, + mergeSuppressions, + pruneSuppressions, +} from '../src/util/suppressions.js'; +import baseCounters from './helpers/baseCounters.js'; +import { createOptions } from './helpers/create-options.js'; +import { resolve } from './helpers/resolve.js'; + +const cwd = resolve('fixtures/suppressions'); + +test('Baseline: fixture produces expected issues without suppressions', async () => { + const options = await createOptions({ cwd }); + const { issues, counters } = await main(options); + + assert(issues.files.size > 0); + assert(issues.exports['module.ts']['unusedExport']); + assert(issues.exports['module.ts']['anotherUnused']); + assert(issues.dependencies['package.json']['used-pkg']); + assert(issues.dependencies['package.json']['unused-pkg']); + + assert.deepEqual(counters, { + ...baseCounters, + files: 1, + dependencies: 2, + exports: 2, + processed: 3, + total: 3, + }); +}); + +test('generateSuppressions creates correct structure', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const suppressions = generateSuppressions(issues); + + assert.equal(suppressions.version, 1); + assert(suppressions.suppressions['unused.ts']); + const fileSymbols = suppressions.suppressions['unused.ts'].files; + assert(fileSymbols); + assert(fileSymbols['unused.ts']); + + assert(suppressions.suppressions['module.ts']); + const exportSymbols = suppressions.suppressions['module.ts'].exports; + assert(exportSymbols); + assert(exportSymbols['anotherUnused']); + assert(exportSymbols['unusedExport']); + + assert(suppressions.suppressions['package.json']); + const depSymbols = suppressions.suppressions['package.json'].dependencies; + assert(depSymbols); + assert(depSymbols['unused-pkg']); + assert(depSymbols['used-pkg']); +}); + +test('generateSuppressions with --include only includes that type', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const rules: Rules = { ...defaultRules }; + for (const type of Object.keys(rules) as (keyof Rules)[]) { + if (type !== 'exports') rules[type] = 'off'; + } + const suppressions = generateSuppressions(issues, undefined, rules); + + assert.equal(suppressions.version, 1); + assert(suppressions.suppressions['module.ts']); + assert(!suppressions.suppressions['unused.ts']); + assert(!suppressions.suppressions['package.json']); +}); + +test('--suppress-all with --include merges with existing', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const existing: Suppressions = { + version: 1, + suppressions: { + 'package.json': { + dependencies: { 'unused-pkg': {} }, + }, + }, + }; + + const rules: Rules = { ...defaultRules }; + for (const type of Object.keys(rules) as (keyof Rules)[]) { + if (type !== 'exports') rules[type] = 'off'; + } + const newSuppressions = generateSuppressions(issues, undefined, rules); + const merged = mergeSuppressions(existing, newSuppressions); + + assert(merged.suppressions['module.ts']?.exports); + assert(merged.suppressions['package.json']?.dependencies); + assert(!merged.suppressions['unused.ts']); +}); + +test('applySuppressions filters matching issues', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: {}, anotherUnused: {} }, + }, + 'package.json': { + dependencies: { 'unused-pkg': {} }, + }, + }, + }; + + const result = applySuppressions(issues, suppressions); + + assert.equal(result.suppressedCount, 3); + assert(!issues.exports['module.ts']); + assert(issues.dependencies['package.json']['used-pkg']); + assert(!issues.dependencies['package.json']['unused-pkg']); +}); + +test('applySuppressions respects expired until dates', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: { until: '2020-01-01' }, anotherUnused: { until: '2020-01-01' } }, + }, + }, + }; + + const result = applySuppressions(issues, suppressions); + + assert.equal(result.expiredCount, 2); + assert.equal(result.suppressedCount, 0); + assert(issues.exports['module.ts']['unusedExport']); + assert(issues.exports['module.ts']['anotherUnused']); +}); + +test('applySuppressions keeps future until dates', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: { until: '2099-12-31' } }, + }, + }, + }; + + const result = applySuppressions(issues, suppressions); + + assert.equal(result.suppressedCount, 1); + assert(!issues.exports['module.ts']['unusedExport']); + assert(issues.exports['module.ts']['anotherUnused']); +}); + +test('pruneSuppressions removes expired entries', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: { until: '2026-02-10' } }, + }, + }, + }; + + const updated = pruneSuppressions(issues, suppressions); + + assert(!updated.suppressions['module.ts']); +}); + +test('mergeSuppressions combines two suppressions objects', () => { + const existing: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: {} }, + }, + }, + }; + + const incoming: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { anotherUnused: {} }, + }, + 'package.json': { + dependencies: { 'unused-pkg': {} }, + }, + }, + }; + + const merged = mergeSuppressions(existing, incoming); + + assert.equal(merged.version, 1); + assert(merged.suppressions['module.ts']); + const symbols = merged.suppressions['module.ts'].exports; + assert(symbols); + assert(symbols['anotherUnused']); + assert(symbols['unusedExport']); + assert(merged.suppressions['package.json']); +}); + +test('generateSuppressions skips warn-level issue types', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const rules: Rules = { ...defaultRules, exports: 'warn' }; + const suppressions = generateSuppressions(issues, undefined, rules); + + assert(!suppressions.suppressions['module.ts']?.exports); + assert(suppressions.suppressions['package.json']?.dependencies); + assert(suppressions.suppressions['unused.ts']?.files); +}); + +test('applySuppressions skips warn-level issue types', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: {}, anotherUnused: {} }, + }, + 'package.json': { + dependencies: { 'unused-pkg': {} }, + }, + }, + }; + + const rules: Rules = { ...defaultRules, exports: 'warn' }; + const result = applySuppressions(issues, suppressions, rules); + + assert.equal(result.suppressedCount, 1); + assert(issues.exports['module.ts']['unusedExport']); + assert(issues.exports['module.ts']['anotherUnused']); + assert(!issues.dependencies['package.json']['unused-pkg']); +}); + +test('mergeSuppressions preserves existing symbol metadata', () => { + const existing: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: {} }, + }, + }, + }; + + const incoming: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: { until: '2026-12-31' }, anotherUnused: { until: '2026-12-31' } }, + }, + }, + }; + + const merged = mergeSuppressions(existing, incoming); + + const symbols = merged.suppressions['module.ts'].exports; + assert(symbols); + assert.equal(symbols['unusedExport'].until, undefined); + assert.equal(symbols['anotherUnused'].until, '2026-12-31'); +}); + +test('mergeSuppressions preserves until on new symbols', () => { + const existing: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: { until: '2026-12-31' } }, + }, + }, + }; + + const incoming: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { anotherUnused: { until: '2027-06-01' } }, + }, + }, + }; + + const merged = mergeSuppressions(existing, incoming); + + const symbols = merged.suppressions['module.ts'].exports; + assert(symbols); + assert.equal(symbols['unusedExport'].until, '2026-12-31'); + assert.equal(symbols['anotherUnused'].until, '2027-06-01'); +}); + +test('Regular run auto-prunes stale suppressions', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const existing: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: {}, anotherUnused: {}, alreadyFixed: {} }, + }, + 'deleted-file.ts': { + exports: { gone: {} }, + }, + }, + }; + + const updated = pruneSuppressions(issues, existing); + const isChanged = JSON.stringify(existing) !== JSON.stringify(updated); + + assert(isChanged); + assert(updated.suppressions['module.ts']?.exports?.['unusedExport']); + assert(updated.suppressions['module.ts']?.exports?.['anotherUnused']); + assert(!updated.suppressions['module.ts']?.exports?.['alreadyFixed']); + assert(!updated.suppressions['deleted-file.ts']); +}); + +test('Regular run detects no change when suppressions match', async () => { + const options = await createOptions({ cwd }); + const { issues } = await main(options); + + const existing: Suppressions = { + version: 1, + suppressions: { + 'module.ts': { + exports: { unusedExport: {}, anotherUnused: {} }, + }, + }, + }; + + const updated = pruneSuppressions(issues, existing); + const isChanged = JSON.stringify(existing) !== JSON.stringify(updated); + + assert(!isChanged); +}); diff --git a/packages/knip/test/util/suppressions.test.ts b/packages/knip/test/util/suppressions.test.ts new file mode 100644 index 000000000..488af7d63 --- /dev/null +++ b/packages/knip/test/util/suppressions.test.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ISSUE_TYPES } from '../../src/constants.js'; +import type { Issues } from '../../src/types/issues.js'; +import type { Suppressions } from '../../src/types/suppressions.js'; +import { applySuppressions, mergeSuppressions, pruneSuppressions, stringify } from '../../src/util/suppressions.js'; + +const createEmptyIssues = (): Issues => { + const issues: any = { files: new Set(), _files: {} }; + for (const type of ISSUE_TYPES) { + if (type !== 'files') issues[type] = {}; + } + return issues as Issues; +}; + +test('applySuppressions: suppresses matching issues', () => { + const issues = createEmptyIssues(); + issues.exports['file.ts'] = { foo: { symbol: 'foo' } } as any; + + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'file.ts': { + exports: { foo: {} }, + }, + }, + }; + + const result = applySuppressions(issues, suppressions); + + assert.equal(result.suppressedCount, 1); + assert.equal(issues.exports['file.ts'], undefined); +}); + +test('applySuppressions: ignores unmatched suppressions', () => { + const issues = createEmptyIssues(); + + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'file.ts': { + exports: { foo: {} }, + }, + }, + }; + + const result = applySuppressions(issues, suppressions); + + assert.equal(result.suppressedCount, 0); +}); + +test('applySuppressions: counts expired suppressions', () => { + const issues = createEmptyIssues(); + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'expired.ts': { + files: { 'expired.ts': { until: '2000-01-01' } }, + }, + }, + }; + + const result = applySuppressions(issues, suppressions); + + assert.equal(result.expiredCount, 1); + assert.equal(result.suppressedCount, 0); +}); + +test('pruneSuppressions: preserves custom fields', () => { + const issues = createEmptyIssues(); + issues.exports['file.ts'] = { foo: { symbol: 'foo' } } as any; + + const suppressions: Suppressions = { + version: 1, + suppressions: { + 'file.ts': { + exports: { + foo: { ticket: 'JIRA-123' }, + bar: {}, + } as any, + }, + }, + }; + + const result = pruneSuppressions(issues, suppressions); + const entry = result.suppressions['file.ts'].exports as any; + + assert.equal(entry.foo.ticket, 'JIRA-123'); + assert.equal(entry.bar, undefined); +}); + +test('mergeSuppressions: preserves custom fields from existing', () => { + const existing = { + version: 1, + suppressions: { + 'file.ts': { + exports: { + foo: { ticket: 'JIRA-789' }, + } as any, + }, + }, + }; + + const incoming = { + version: 1, + suppressions: { + 'file.ts': { + exports: { bar: {} }, + }, + }, + }; + + const result = mergeSuppressions(existing, incoming); + const entry = result.suppressions['file.ts'].exports as any; + + assert.equal(entry.foo.ticket, 'JIRA-789'); + assert.deepEqual(entry.bar, {}); +}); + +test('pruneSuppressions: preserves custom fields on files entries', () => { + const issues = createEmptyIssues(); + issues._files['old.ts'] = { + 'old.ts': { type: 'files', filePath: '/old.ts', symbol: 'old.ts', severity: 'error', fixes: [] }, + } as any; + + const suppressions = { + version: 1, + suppressions: { + 'old.ts': { + files: { + 'old.ts': { until: '3000-01-01', ticket: 'JIRA-123' }, + } as any, + }, + }, + }; + + const result = pruneSuppressions(issues, suppressions); + const entry = result.suppressions['old.ts'].files as any; + + assert.equal(entry['old.ts'].until, '3000-01-01'); + assert.equal(entry['old.ts'].ticket, 'JIRA-123'); +}); + +test('stringify: sorts keys at all levels', () => { + const output = stringify({ + version: 1, + suppressions: { + 'z-file.ts': { + exports: { beta: {}, alpha: {} }, + dependencies: { zlib: {} }, + }, + 'a-file.ts': { + files: { 'a-file.ts': {} }, + }, + }, + }); + + const lines = output.split('\n'); + assert.equal(lines[0], '{'); + assert.equal(lines[1], ' "version": 1,'); + assert.equal(lines[2], ' "suppressions": {'); + assert.equal(lines[3], ' "a-file.ts": {'); + assert.equal(lines[8], ' "z-file.ts": {'); + assert.equal(lines[9], ' "dependencies": {'); + assert.equal(lines[12], ' "exports": {'); + assert.equal(lines[13], ' "alpha": {},'); + assert.equal(lines[14], ' "beta": {}'); +}); + +test('stringify: produces valid JSON that round-trips', () => { + const input: Suppressions = { + version: 1, + suppressions: { + 'file.ts': { + exports: { foo: { until: '2026-12-31' }, bar: {} }, + }, + }, + }; + + const output = stringify(input); + const parsed = JSON.parse(output); + + assert.equal(parsed.version, 1); + assert.deepEqual(parsed.suppressions['file.ts'].exports.foo, { until: '2026-12-31' }); + assert.deepEqual(parsed.suppressions['file.ts'].exports.bar, {}); +}); + +test('stringify: one line per symbol', () => { + const output = stringify({ + version: 1, + suppressions: { + 'file.ts': { + exports: { + plain: {}, + withMeta: { until: '2026-12-31' }, + }, + }, + }, + }); + + const lines = output.split('\n'); + const plainLine = lines.find(l => l.includes('"plain"')); + const metaLine = lines.find(l => l.includes('"withMeta"')); + assert.equal(plainLine, ' "plain": {},'); + assert.equal(metaLine, ' "withMeta": {"until":"2026-12-31"}'); +}); + +test('stringify: ends with trailing newline', () => { + const output = stringify({ version: 1, suppressions: {} }); + assert.ok(output.endsWith('\n')); + assert.ok(!output.endsWith('\n\n')); +});