diff --git a/CHANGELOG.md b/CHANGELOG.md index 4364d51..4a2e6c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,21 @@ All notable changes to DebtLens are documented here. This project adheres to - **`debtlens/plugin` entry point** exporting `Detector`, `DetectorContext`, `DebtIssue`, `Severity`, and `DEBTLENS_PLUGIN_API_VERSION` for plugin authors ([#70](https://github.com/ColumbusLabs/DebtLens/issues/70)). +- **Plugin threshold defaults**: plugins can export a `thresholds` map merged after + built-in defaults, so user config and `--threshold` still override + ([#73](https://github.com/ColumbusLabs/DebtLens/issues/73)). +- **Plugin vocabulary groups**: plugins can export naming-drift `vocabulary` concept + groups, overridden by user config groups with the same id + ([#74](https://github.com/ColumbusLabs/DebtLens/issues/74)). +- **`ruleSeverities` config field** replacing the severity a rule reports, for + downgrading noisy rules without disabling them; unknown rule ids warn with a + did-you-mean suggestion ([#107](https://github.com/ColumbusLabs/DebtLens/issues/107)). +- **`ruleConfidenceFloors` config field** hiding findings from a rule below a minimum + confidence, tracked under `summary.filterStats.filteredByConfidenceFloor` + ([#108](https://github.com/ColumbusLabs/DebtLens/issues/108)). +- **`debtlens suppress`** helper printing a copy-paste inline suppression directive + (`--rule`, `--reason`, optional `--file`) + ([#146](https://github.com/ColumbusLabs/DebtLens/issues/146)). ## [0.3.0] - 2026-06-09 diff --git a/README.md b/README.md index cfbe4d3..74c3c41 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ debtlens packs # list built-in rule pack presets debtlens doctor # inspect resolved config and matched files without scanning debtlens rules # list built-in rule ids and descriptions debtlens explain # print rule docs, default thresholds, and false-positive guidance +debtlens suppress --rule --reason "" # print a copy-paste inline suppression comment debtlens scan [target] ``` @@ -235,6 +236,16 @@ Rules: Terminal output includes inline suppression counts in the filter stats line (for example, `1 inline suppressed`). JSON reports expose the same count under `summary.filterStats.suppressedByInline`. +`debtlens suppress` prints a ready-to-paste directive so you don't have to remember the syntax: + +```bash +debtlens suppress --rule todo-comment --reason "tracked in PROJ-123" +# // debtlens-disable-next-line todo-comment -- tracked in PROJ-123 + +debtlens suppress --rule naming-drift --reason "domain vocabulary is intentional" --file +# // debtlens-disable-file naming-drift -- domain vocabulary is intentional +``` + Prefer baselines for legacy debt, config tuning for false positives, and inline suppressions for rare, documented exceptions. See [`docs/rules.md`](./docs/rules.md#suppressing-findings) for guidance. ## Configuration @@ -293,6 +304,27 @@ Built-in presets select a rule set without hand-picking every rule id. See [`doc Explicit `rules` in config override the pack. Use `debtlens packs` to list presets. +### Per-rule severities and confidence floors + +Tune noisy rules without disabling them. `ruleSeverities` replaces the severity a rule +reports (changing summary counts and `--fail-on` behavior), and `ruleConfidenceFloors` +hides findings from a rule below a minimum confidence: + +```json +{ + "ruleSeverities": { + "naming-drift": "info" + }, + "ruleConfidenceFloors": { + "prop-drilling": 0.8 + } +} +``` + +Unknown rule ids in either map emit a warning with a did-you-mean suggestion. Issues +hidden by a confidence floor are counted under `summary.filterStats.filteredByConfidenceFloor`. +Both maps accept plugin rule ids when plugins are configured. + ### Custom naming vocabulary `naming-drift` ships with a built-in media/release vocabulary. Add your own domain concepts with `vocabulary` (concept id → competing terms). Your groups are merged with the built-ins, and a group with the same id overrides the built-in one. @@ -326,6 +358,19 @@ Plugin authors import types from the published `debtlens/plugin` entry point: import type { Detector, DetectorContext } from "debtlens/plugin"; ``` +Besides `rules`, a plugin's default export may include `thresholds` (defaults read by +`context.getThreshold`, merged after built-ins so user config and `--threshold` still +override them) and `vocabulary` (naming-drift concept groups, overridden by user config +groups with the same id): + +```js +export default { + rules: [noConsoleDetector], + thresholds: { "no-console.maxCalls": 0 }, + vocabulary: { logging: ["log", "logger", "console", "debug", "trace"] }, +}; +``` + See the reference plugin in [`examples/plugin/`](./examples/plugin/) and the full contract in [`docs/plugin-api-rfc.md`](./docs/plugin-api-rfc.md). Plugin paths must stay within the config file's directory tree, rule ids must not collide with built-ins, and diff --git a/docs/plugin-api-rfc.md b/docs/plugin-api-rfc.md index 9ff4b24..0cfadae 100644 --- a/docs/plugin-api-rfc.md +++ b/docs/plugin-api-rfc.md @@ -1,6 +1,6 @@ # Plugin API RFC -Status: **Shipped (v1)** — the loader, `pluginApiVersion` validation, and the `DEBTLENS_DISABLE_PLUGINS` escape hatch are implemented. Follow-ons: plugin threshold defaults ([#73](https://github.com/ColumbusLabs/DebtLens/issues/73)) and vocabulary merging ([#74](https://github.com/ColumbusLabs/DebtLens/issues/74)). +Status: **Shipped (v1)** — the loader, `pluginApiVersion` validation, the `DEBTLENS_DISABLE_PLUGINS` escape hatch, plugin threshold defaults ([#73](https://github.com/ColumbusLabs/DebtLens/issues/73)), and vocabulary merging ([#74](https://github.com/ColumbusLabs/DebtLens/issues/74)) are implemented. ## Problem @@ -63,7 +63,13 @@ Issues must include `message`, `severity`, `confidence`, `file`, `location`, `ev Each plugin module default-exports either: - a single `Detector`, or -- `{ rules: Detector[], vocabulary?: Record }` +- `{ rules: Detector[], thresholds?: Record, vocabulary?: Record }` + +`thresholds` supplies defaults for `context.getThreshold` keys; they merge after +built-in defaults, so user config `thresholds` and the `--threshold` flag override +them. `vocabulary` contributes naming-drift concept groups; user config groups with +the same id override plugin groups. When multiple plugins set the same threshold key +or concept id, the later plugin wins and a warning is emitted. ## Loading model @@ -142,8 +148,8 @@ A runnable version of this plugin lives in [`examples/plugin/`](../examples/plug ## Open questions -- Should plugins export threshold defaults? -- Allow vocabulary packs from plugins? +- ~~Should plugins export threshold defaults?~~ Shipped ([#73](https://github.com/ColumbusLabs/DebtLens/issues/73)): `thresholds` export merges after built-in defaults, before user config. +- ~~Allow vocabulary packs from plugins?~~ Shipped ([#74](https://github.com/ColumbusLabs/DebtLens/issues/74)): `vocabulary` export merges below user config groups. - Per-plugin enable flags vs flat `rules` list? Track decisions in issue [#26](https://github.com/ColumbusLabs/DebtLens/issues/26). diff --git a/examples/plugin/no-console.mjs b/examples/plugin/no-console.mjs index d06f31c..07eccec 100644 --- a/examples/plugin/no-console.mjs +++ b/examples/plugin/no-console.mjs @@ -10,11 +10,18 @@ const noConsoleDetector = { defaultSeverity: "low", tags: ["hygiene"], detect(context) { + // Plugin-exported threshold default (see `thresholds` below); user config + // `thresholds["no-console.maxCalls"]` overrides it. + const maxCalls = context.getThreshold("no-console.maxCalls", 0); const issues = []; for (const file of context.files) { const lines = file.content.split(/\r?\n/); + const matches = []; for (let index = 0; index < lines.length; index += 1) { - if (!lines[index].includes("console.log")) continue; + if (lines[index].includes("console.log")) matches.push(index); + } + if (matches.length <= maxCalls) continue; + for (const index of matches) { issues.push({ id: `dl_nc_${file.relativePath}:${index + 1}`, ruleId: "no-console", @@ -34,4 +41,12 @@ const noConsoleDetector = { }, }; -export default { rules: [noConsoleDetector] }; +export default { + rules: [noConsoleDetector], + // Threshold defaults merged after built-ins, before user config and CLI flags. + thresholds: { "no-console.maxCalls": 0 }, + // Naming-drift concept groups merged below user config `vocabulary` groups. + vocabulary: { + logging: ["log", "logger", "console", "debug", "trace", "print"], + }, +}; diff --git a/schema/debtlens.config.schema.json b/schema/debtlens.config.schema.json index 0415f99..a3a5aa5 100644 --- a/schema/debtlens.config.schema.json +++ b/schema/debtlens.config.schema.json @@ -145,6 +145,135 @@ } } }, + "ruleSeverities": { + "type": "object", + "description": "Rule id -> severity reported for that rule's issues, replacing the detector's choice. May include plugin rule ids.", + "properties": { + "large-component": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + }, + "state-sprawl": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + }, + "effect-complexity": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + }, + "duplicate-logic": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + }, + "dead-abstraction": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + }, + "prop-drilling": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + }, + "todo-comment": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + }, + "naming-drift": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + } + }, + "additionalProperties": { + "enum": [ + "info", + "low", + "medium", + "high" + ] + } + }, + "ruleConfidenceFloors": { + "type": "object", + "description": "Rule id -> minimum confidence (0-1); issues from that rule below the floor are not reported. May include plugin rule ids.", + "properties": { + "large-component": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "state-sprawl": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "effect-complexity": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "duplicate-logic": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "dead-abstraction": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prop-drilling": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "todo-comment": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "naming-drift": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, + "additionalProperties": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + }, "propDrilling": { "type": "object", "description": "Prop-drilling rule configuration.", diff --git a/src/cli/index.ts b/src/cli/index.ts index 8bd78c7..8f8ac0d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,13 +10,14 @@ import { DEFAULT_BASELINE_FILENAME, applyBaseline, createBaseline, loadBaseline, import { scan } from "../core/scan.js"; import { getChangedFiles, getRefSnapshot, getStagedFiles } from "../utils/git.js"; import { parseSeverity, severityRank } from "../core/severity.js"; -import type { DebtIssue, DebtLensConfig, Detector, OutputFormat, ScanOptions, ScanResult, Severity } from "../core/types.js"; +import type { DebtIssue, DebtLensConfig, Detector, OutputFormat, ScanOptions, ScanResult, ScanThresholds, Severity } from "../core/types.js"; import { allDetectors, detectorIds } from "../detectors/index.js"; import { loadPlugins } from "../plugins/loadPlugins.js"; import { packageVersion } from "../utils/packageInfo.js"; import { renderReport } from "../reporters/index.js"; import { runExplain } from "./explain.js"; import { runInit } from "./init.js"; +import { runSuppress } from "./suppress.js"; import { runDoctor } from "./doctor.js"; import { runAdopt } from "./adopt.js"; import { parseCommaList, parseThresholds } from "./parseList.js"; @@ -60,7 +61,7 @@ program.command("scan") const format = parseFormat(String(rawOptions.format ?? "terminal")); const cwd = resolve(String(rawOptions.cwd ?? process.cwd())); const fileConfig = loadConfig(cwd, rawOptions.config ? String(rawOptions.config) : undefined); - const pluginDetectors = await loadConfiguredPlugins(cwd, rawOptions, fileConfig); + const pluginContribution = await loadConfiguredPlugins(cwd, rawOptions, fileConfig); const minSeverity = parseSeverity(String(rawOptions.minSeverity ?? "low"), "low"); const failOn = resolveFailOn(rawOptions, fileConfig); const failOnConfidence = resolveFailOnConfidence(rawOptions, fileConfig); @@ -108,7 +109,9 @@ program.command("scan") changedFiles, fileContents, profile: rawOptions.profile === true, - pluginDetectors, + pluginDetectors: pluginContribution?.detectors, + pluginThresholds: pluginContribution?.thresholds, + pluginVocabulary: pluginContribution?.vocabulary, }); if (rawOptions.writeBaseline && rawOptions.baseline) { @@ -317,6 +320,25 @@ program.command("explain") } }); +program.command("suppress") + .description("Print a copy-paste inline suppression comment for a finding.") + .requiredOption("--rule ", "rule id to suppress, e.g. todo-comment") + .requiredOption("--reason ", "why the finding is acceptable (required by the scanner)") + .option("--file", "emit a file-level directive instead of next-line") + .action((rawOptions: Record) => { + try { + process.stdout.write(runSuppress({ + ruleId: String(rawOptions.rule), + reason: String(rawOptions.reason), + file: rawOptions.file === true, + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`DebtLens failed: ${message}\n`); + process.exitCode = 1; + } + }); + program.command("init") .description("Create a starter debtlens.config.json in the current directory.") .option("--force", "overwrite an existing config file") @@ -420,11 +442,17 @@ function parseConfidence(value: string): number { return parsed; } +interface PluginContribution { + detectors?: Detector[]; + thresholds?: ScanThresholds; + vocabulary?: Record; +} + async function loadConfiguredPlugins( cwd: string, rawOptions: Record, fileConfig: DebtLensConfig, -): Promise { +): Promise { if (!fileConfig.plugins?.length) return undefined; const configPath = findConfigPath(cwd, rawOptions.config ? String(rawOptions.config) : undefined); @@ -433,7 +461,11 @@ async function loadConfiguredPlugins( for (const warning of loaded.warnings) { process.stderr.write(`DebtLens: ${warning}\n`); } - return loaded.detectors.length > 0 ? loaded.detectors : undefined; + return { + detectors: loaded.detectors.length > 0 ? loaded.detectors : undefined, + thresholds: Object.keys(loaded.thresholds).length > 0 ? loaded.thresholds : undefined, + vocabulary: Object.keys(loaded.vocabulary).length > 0 ? loaded.vocabulary : undefined, + }; } function resolveFailOn( diff --git a/src/cli/suppress.ts b/src/cli/suppress.ts new file mode 100644 index 0000000..0de36b2 --- /dev/null +++ b/src/cli/suppress.ts @@ -0,0 +1,32 @@ +import { allDetectors } from "../detectors/index.js"; +import { suggestClosest } from "../utils/didYouMean.js"; + +export interface SuppressOptions { + ruleId: string; + reason: string; + /** When true, emit a file-level directive instead of next-line. */ + file?: boolean; +} + +/** + * Render a copy-paste inline suppression directive for `debtlens suppress`. + * The output must stay parseable by src/core/suppressions.ts, including the + * `-- reason` segment the scanner requires before honoring a suppression. + */ +export function runSuppress(options: SuppressOptions): string { + const normalized = options.ruleId.toLowerCase(); + const detector = allDetectors.find((candidate) => candidate.id === normalized); + if (!detector) { + const suggestion = suggestClosest(normalized, allDetectors.map((candidate) => candidate.id)); + const hint = suggestion ? ` Did you mean "${suggestion}"?` : ""; + throw new Error(`Unknown DebtLens rule "${options.ruleId}".${hint} Run "debtlens rules" to list available rules.`); + } + + const reason = options.reason.trim(); + if (!reason) { + throw new Error("A non-empty --reason is required; DebtLens ignores suppressions without one."); + } + + const directive = options.file ? "debtlens-disable-file" : "debtlens-disable-next-line"; + return `// ${directive} ${detector.id} -- ${reason}\n`; +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 8704941..215d920 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,6 +1,6 @@ import type { DebtLensConfig } from "../core/types.js"; -export const defaultConfig: Required> = { +export const defaultConfig: Required> = { include: ["**/*.{ts,tsx,js,jsx}"], exclude: [ "node_modules/**", diff --git a/src/config/mergeConfig.ts b/src/config/mergeConfig.ts index 4080d8a..f59ccfc 100644 --- a/src/config/mergeConfig.ts +++ b/src/config/mergeConfig.ts @@ -2,7 +2,8 @@ import { resolve } from "node:path"; import { defaultConfig } from "./defaults.js"; import { getRulePack } from "./packs.js"; import { compileTodoCommentMarkers } from "../detectors/todoComment.js"; -import type { CliOptions, DebtLensConfig, ScanOptions } from "../core/types.js"; +import { isSeverity, severities } from "../core/severity.js"; +import type { CliOptions, DebtLensConfig, ScanOptions, Severity } from "../core/types.js"; export function mergeConfig(target: string, fileConfig: DebtLensConfig, cliOptions: CliOptions): ScanOptions { const cwd = resolve(cliOptions.cwd ?? process.cwd()); @@ -32,12 +33,17 @@ export function mergeConfig(target: string, fileConfig: DebtLensConfig, cliOptio rules, thresholds: { ...defaultConfig.thresholds, + ...(cliOptions.pluginThresholds ?? {}), ...(fileConfig.thresholds ?? {}), ...(cliOptions.thresholds ?? {}), }, maxFiles: cliOptions.maxFiles ?? fileConfig.maxFiles ?? defaultConfig.maxFiles, respectGitignore: cliOptions.respectGitignore ?? fileConfig.respectGitignore ?? defaultConfig.respectGitignore, - vocabulary: { ...defaultConfig.vocabulary, ...(fileConfig.vocabulary ?? {}) }, + vocabulary: { + ...defaultConfig.vocabulary, + ...(cliOptions.pluginVocabulary ?? {}), + ...(fileConfig.vocabulary ?? {}), + }, namingDriftDisableBuiltInVocabulary: fileConfig.namingDrift?.disableBuiltInVocabulary ?? defaultConfig.namingDrift.disableBuiltInVocabulary, propDrillingIgnoreComponents: [ @@ -53,5 +59,33 @@ export function mergeConfig(target: string, fileConfig: DebtLensConfig, cliOptio fileContents: cliOptions.fileContents, profile: cliOptions.profile, pluginDetectors: cliOptions.pluginDetectors, + ruleSeverities: validateRuleSeverities(fileConfig.ruleSeverities), + ruleConfidenceFloors: validateRuleConfidenceFloors(fileConfig.ruleConfidenceFloors), }; } + +function validateRuleSeverities(ruleSeverities: DebtLensConfig["ruleSeverities"]): Record | undefined { + if (!ruleSeverities) return undefined; + for (const [ruleId, severity] of Object.entries(ruleSeverities)) { + if (!isSeverity(severity)) { + throw new Error( + `Config "ruleSeverities.${ruleId}" must be one of ${severities.join(", ")}; received "${String(severity)}".`, + ); + } + } + return ruleSeverities; +} + +function validateRuleConfidenceFloors( + ruleConfidenceFloors: DebtLensConfig["ruleConfidenceFloors"], +): Record | undefined { + if (!ruleConfidenceFloors) return undefined; + for (const [ruleId, floor] of Object.entries(ruleConfidenceFloors)) { + if (typeof floor !== "number" || !Number.isFinite(floor) || floor < 0 || floor > 1) { + throw new Error( + `Config "ruleConfidenceFloors.${ruleId}" must be a number between 0 and 1; received "${String(floor)}".`, + ); + } + } + return ruleConfidenceFloors; +} diff --git a/src/config/schema.ts b/src/config/schema.ts index 6db3d16..eec6eb9 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -15,6 +15,10 @@ export function buildConfigSchema(): Record { const knownThresholds = Object.fromEntries( Object.keys(defaultConfig.thresholds).map((key) => [key, { type: "number" }]), ); + const severityValue = { enum: [...severities] }; + const confidenceFloorValue = { type: "number", minimum: 0, maximum: 1 }; + const knownRuleSeverities = Object.fromEntries(detectorIds.map((id) => [id, severityValue])); + const knownRuleConfidenceFloors = Object.fromEntries(detectorIds.map((id) => [id, confidenceFloorValue])); return { $schema: "http://json-schema.org/draft-07/schema#", @@ -87,6 +91,18 @@ export function buildConfigSchema(): Record { items: { type: "string" }, }, }, + ruleSeverities: { + type: "object", + description: "Rule id -> severity reported for that rule's issues, replacing the detector's choice. May include plugin rule ids.", + properties: knownRuleSeverities, + additionalProperties: severityValue, + }, + ruleConfidenceFloors: { + type: "object", + description: "Rule id -> minimum confidence (0-1); issues from that rule below the floor are not reported. May include plugin rule ids.", + properties: knownRuleConfidenceFloors, + additionalProperties: confidenceFloorValue, + }, propDrilling: { type: "object", description: "Prop-drilling rule configuration.", diff --git a/src/core/scan.ts b/src/core/scan.ts index 1a8df52..072f27a 100644 --- a/src/core/scan.ts +++ b/src/core/scan.ts @@ -43,8 +43,13 @@ export async function scan(options: ScanOptions): Promise { let issues: DebtIssue[] = []; const warnings: string[] = []; let filteredByMinSeverity = 0; + let filteredByConfidenceFloor = 0; const ruleTimingsMs: Record = {}; + for (const warning of validatePerRuleOverrides(registry, options)) { + if (!warnings.includes(warning)) warnings.push(warning); + } + for (const detector of detectors) { const detectorStartedAt = options.profile ? Date.now() : 0; const detectorIssues = await detector.detect({ @@ -57,6 +62,15 @@ export async function scan(options: ScanOptions): Promise { }, }); for (const issue of detectorIssues) { + const severityOverride = options.ruleSeverities?.[issue.ruleId]; + if (severityOverride) { + issue.severity = severityOverride; + } + const confidenceFloor = options.ruleConfidenceFloors?.[issue.ruleId]; + if (confidenceFloor !== undefined && issue.confidence < confidenceFloor) { + filteredByConfidenceFloor += 1; + continue; + } if (meetsMinSeverity(issue.severity, options.minSeverity)) { issues.push(issue); } else { @@ -85,6 +99,7 @@ export async function scan(options: ScanOptions): Promise { const filterStats = { ...(filteredByMinSeverity > 0 ? { filteredByMinSeverity } : {}), + ...(filteredByConfidenceFloor > 0 ? { filteredByConfidenceFloor } : {}), ...(suppression.suppressedByInline > 0 ? { suppressedByInline: suppression.suppressedByInline } : {}), }; @@ -151,3 +166,25 @@ function getThreshold(options: ScanOptions, key: string, fallback: number): numb const value = options.thresholds[key]; return Number.isFinite(value) ? value : fallback; } + +/** Warn (not fail) on per-rule override keys that match no known rule, so typos surface. */ +function validatePerRuleOverrides(registry: Detector[], options: ScanOptions): string[] { + const knownIds = registry.map((detector) => detector.id); + const knownIdSet = new Set(knownIds); + const warnings: string[] = []; + + const describeUnknown = (configKey: string, ruleId: string) => { + const suggestion = suggestClosest(ruleId, knownIds); + const hint = suggestion ? ` (did you mean "${suggestion}"?)` : ""; + return `${configKey}: unknown rule "${ruleId}"${hint}`; + }; + + for (const ruleId of Object.keys(options.ruleSeverities ?? {})) { + if (!knownIdSet.has(ruleId)) warnings.push(describeUnknown("ruleSeverities", ruleId)); + } + for (const ruleId of Object.keys(options.ruleConfidenceFloors ?? {})) { + if (!knownIdSet.has(ruleId)) warnings.push(describeUnknown("ruleConfidenceFloors", ruleId)); + } + + return warnings; +} diff --git a/src/core/types.ts b/src/core/types.ts index 1017039..43bf361 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -74,6 +74,10 @@ export interface DebtLensConfig { failOn?: Severity; /** Exit with code 1 only when a reported issue meets `--fail-on` and this confidence floor. */ failOnConfidence?: number; + /** Rule id -> severity reported for that rule's issues, replacing the detector's choice. */ + ruleSeverities?: Record; + /** Rule id -> minimum confidence; issues from that rule below the floor are not reported. */ + ruleConfidenceFloors?: Record; } export interface ScanOptions { @@ -105,6 +109,10 @@ export interface ScanOptions { profile?: boolean; /** Detectors contributed by config-loaded plugins, merged after built-in rules. */ pluginDetectors?: Detector[]; + /** Rule id -> severity reported for that rule's issues, replacing the detector's choice. */ + ruleSeverities?: Record; + /** Rule id -> minimum confidence; issues from that rule below the floor are not reported. */ + ruleConfidenceFloors?: Record; } export interface CliOptions { @@ -127,6 +135,10 @@ export interface CliOptions { fileContents?: Record; profile?: boolean; pluginDetectors?: Detector[]; + /** Threshold defaults contributed by plugins; user config and CLI thresholds override. */ + pluginThresholds?: ScanThresholds; + /** Naming-drift vocabulary contributed by plugins; user config groups override on id. */ + pluginVocabulary?: Record; } export interface DetectorContext { @@ -148,6 +160,7 @@ export interface Detector { export interface ScanFilterStats { filteredByMinSeverity?: number; + filteredByConfidenceFloor?: number; suppressedByBaseline?: number; suppressedByInline?: number; } diff --git a/src/plugins/loadPlugins.ts b/src/plugins/loadPlugins.ts index 3075189..fb8c76a 100644 --- a/src/plugins/loadPlugins.ts +++ b/src/plugins/loadPlugins.ts @@ -2,10 +2,14 @@ import { existsSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { isSeverity } from "../core/severity.js"; -import type { DebtLensConfig, Detector } from "../core/types.js"; +import type { DebtLensConfig, Detector, ScanThresholds } from "../core/types.js"; export interface PluginLoadResult { detectors: Detector[]; + /** Threshold defaults exported by plugins; user config and CLI values override. */ + thresholds: ScanThresholds; + /** Naming-drift concept groups exported by plugins; user config groups override on id. */ + vocabulary: Record; warnings: string[]; } @@ -19,7 +23,9 @@ export function pluginsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { * Load third-party detectors from local ESM modules listed in config `plugins[]`. * Paths resolve relative to the config file directory and must stay within it; * exported detectors are validated against the built-in `Detector` contract and - * must not collide with built-in or other plugin rule ids. + * must not collide with built-in or other plugin rule ids. Plugins may also + * export `thresholds` (defaults merged below user config) and `vocabulary` + * (naming-drift concept groups merged below user config groups). * See docs/plugin-api-rfc.md for the full loading model. */ export async function loadPlugins( @@ -31,15 +37,17 @@ export async function loadPlugins( const warnings: string[] = []; const pluginPaths = config.plugins ?? []; if (pluginPaths.length === 0) { - return { detectors: [], warnings }; + return { detectors: [], thresholds: {}, vocabulary: {}, warnings }; } if (pluginsDisabled(env)) { warnings.push("plugins configured but skipped because DEBTLENS_DISABLE_PLUGINS=1"); - return { detectors: [], warnings }; + return { detectors: [], thresholds: {}, vocabulary: {}, warnings }; } const detectors: Detector[] = []; + const thresholds: ScanThresholds = {}; + const vocabulary: Record = {}; const seenRuleIds = new Set(builtInRuleIds); for (const pluginPath of pluginPaths) { @@ -62,12 +70,9 @@ export async function loadPlugins( throw new Error(`Could not load plugin "${pluginPath}": ${message}`); } - const { rules, vocabulary } = normalizePluginExport(moduleExports.default, pluginPath); - if (vocabulary) { - warnings.push(`${pluginPath}: plugin vocabulary export is not supported yet and was ignored`); - } + const exported = normalizePluginExport(moduleExports.default, pluginPath); - for (const candidate of rules) { + for (const candidate of exported.rules) { const detector = validateDetector(candidate, pluginPath); if (seenRuleIds.has(detector.id)) { throw new Error( @@ -77,21 +82,35 @@ export async function loadPlugins( seenRuleIds.add(detector.id); detectors.push(detector); } + + for (const [key, value] of Object.entries(validateThresholds(exported.thresholds, pluginPath))) { + if (key in thresholds) { + warnings.push(`${pluginPath}: threshold "${key}" was already set by an earlier plugin and overrides it`); + } + thresholds[key] = value; + } + + for (const [conceptId, variants] of Object.entries(validateVocabulary(exported.vocabulary, pluginPath))) { + if (conceptId in vocabulary) { + warnings.push(`${pluginPath}: vocabulary group "${conceptId}" was already set by an earlier plugin and overrides it`); + } + vocabulary[conceptId] = variants; + } } - return { detectors, warnings }; + return { detectors, thresholds, vocabulary, warnings }; } function normalizePluginExport( exported: unknown, pluginPath: string, -): { rules: unknown[]; vocabulary?: Record } { +): { rules: unknown[]; thresholds?: unknown; vocabulary?: unknown } { if (exported && typeof exported === "object" && "rules" in exported) { - const shaped = exported as { rules: unknown; vocabulary?: Record }; + const shaped = exported as { rules: unknown; thresholds?: unknown; vocabulary?: unknown }; if (!Array.isArray(shaped.rules)) { throw new Error(`Plugin "${pluginPath}" exports "rules" that is not an array.`); } - return { rules: shaped.rules, vocabulary: shaped.vocabulary }; + return { rules: shaped.rules, thresholds: shaped.thresholds, vocabulary: shaped.vocabulary }; } if (exported && typeof exported === "object") { @@ -103,6 +122,36 @@ function normalizePluginExport( ); } +function validateThresholds(thresholds: unknown, pluginPath: string): ScanThresholds { + if (thresholds === undefined) return {}; + if (!thresholds || typeof thresholds !== "object" || Array.isArray(thresholds)) { + throw new Error(`Plugin "${pluginPath}" exports "thresholds" that is not an object of numbers.`); + } + + for (const [key, value] of Object.entries(thresholds)) { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Plugin "${pluginPath}" threshold "${key}" must be a finite number.`); + } + } + + return thresholds as ScanThresholds; +} + +function validateVocabulary(vocabulary: unknown, pluginPath: string): Record { + if (vocabulary === undefined) return {}; + if (!vocabulary || typeof vocabulary !== "object" || Array.isArray(vocabulary)) { + throw new Error(`Plugin "${pluginPath}" exports "vocabulary" that is not an object of string arrays.`); + } + + for (const [conceptId, variants] of Object.entries(vocabulary)) { + if (!Array.isArray(variants) || variants.length === 0 || variants.some((variant) => typeof variant !== "string")) { + throw new Error(`Plugin "${pluginPath}" vocabulary group "${conceptId}" must be a non-empty array of strings.`); + } + } + + return vocabulary as Record; +} + function validateDetector(candidate: unknown, pluginPath: string): Detector { if (!candidate || typeof candidate !== "object") { throw new Error(`Plugin "${pluginPath}" exports a rule that is not an object.`); diff --git a/tests/cli/plugins.test.ts b/tests/cli/plugins.test.ts index 6d5d54b..734b255 100644 --- a/tests/cli/plugins.test.ts +++ b/tests/cli/plugins.test.ts @@ -118,6 +118,91 @@ describe("debtlens scan with plugins", () => { }); }); + it("applies plugin threshold defaults and lets user config override them", () => { + withPluginProject((dir) => { + const thresholdPluginSource = ` +export default { + rules: [{ + id: "no-console", + name: "No console", + description: "Flags console.log in production source.", + defaultSeverity: "low", + tags: ["hygiene"], + detect(context) { + const maxCalls = context.getThreshold("no-console.maxCalls", 0); + const issues = []; + for (const file of context.files) { + const lines = file.content.split(/\\r?\\n/); + const matches = []; + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].includes("console.log")) matches.push(index); + } + if (matches.length <= maxCalls) continue; + for (const index of matches) { + issues.push({ + id: "dl_nc_" + file.relativePath + ":" + (index + 1), + ruleId: "no-console", + ruleName: "No console", + severity: "low", + confidence: 0.85, + message: "console.log found in source.", + file: file.relativePath, + location: { startLine: index + 1 }, + tags: ["hygiene"], + suggestion: "Remove debug logging.", + }); + } + } + return issues; + }, + }], + thresholds: { "no-console.maxCalls": 1 }, +}; +`; + writeFileSync(join(dir, "no-console.mjs"), thresholdPluginSource); + + // The plugin default (maxCalls: 1) tolerates the single console.log in the fixture. + const withPluginDefault = runScan([".", "--cwd", dir, "--rules", "no-console", "--format", "json"]); + assert.equal(JSON.parse(withPluginDefault.stdout).summary.totalIssues, 0); + + // User config overrides the plugin default back down to zero tolerance. + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify({ + pluginApiVersion: 1, + plugins: ["./no-console.mjs"], + thresholds: { "no-console.maxCalls": 0 }, + })); + const withUserOverride = runScan([".", "--cwd", dir, "--rules", "no-console", "--format", "json"]); + assert.equal(JSON.parse(withUserOverride.stdout).summary.totalIssues, 1); + }); + }); + + it("merges plugin vocabulary into naming-drift concept groups", () => { + withPluginProject((dir) => { + writeFileSync(join(dir, "no-console.mjs"), ` +export default { + rules: [], + vocabulary: { logging: ["log", "logger", "console", "debug", "trace"] }, +}; +`); + writeFileSync(join(dir, "src", "app.ts"), [ + "export const log = 1;", + "export const logger = 2;", + "export const consoleThing = 3;", + "export const debugMode = 4;", + "export const traceLevel = 5;", + "", + ].join("\n")); + + const result = runScan([".", "--cwd", dir, "--rules", "naming-drift", "--min-severity", "info", "--format", "json"]); + const parsed = JSON.parse(result.stdout); + + assert.equal(result.status, 0); + assert.equal(parsed.summary.totalIssues, 1); + assert.equal(parsed.issues[0].ruleId, "naming-drift"); + assert.match(parsed.issues[0].message, /competing terms for logging/); + }); + }); + it("fails with a clear error when a plugin rule id collides", () => { withPluginProject((dir) => { writeFileSync(join(dir, "todo-clone.mjs"), pluginSource.replace(/no-console/g, "todo-comment")); diff --git a/tests/cli/suppress.test.ts b/tests/cli/suppress.test.ts new file mode 100644 index 0000000..795f304 --- /dev/null +++ b/tests/cli/suppress.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { runSuppress } from "../../src/cli/suppress.js"; +import { defaultConfig } from "../../src/config/defaults.js"; +import { scan } from "../../src/core/scan.js"; + +describe("debtlens suppress", () => { + it("prints a next-line suppression directive", () => { + const output = runSuppress({ ruleId: "todo-comment", reason: "tracked in JIRA-123" }); + assert.equal(output, "// debtlens-disable-next-line todo-comment -- tracked in JIRA-123\n"); + }); + + it("prints a file-level suppression directive with --file", () => { + const output = runSuppress({ ruleId: "naming-drift", reason: "domain vocabulary is intentional", file: true }); + assert.equal(output, "// debtlens-disable-file naming-drift -- domain vocabulary is intentional\n"); + }); + + it("normalizes rule id casing", () => { + const output = runSuppress({ ruleId: "TODO-Comment", reason: "why" }); + assert.match(output, /debtlens-disable-next-line todo-comment -- why/); + }); + + it("rejects unknown rules with a suggestion", () => { + assert.throws( + () => runSuppress({ ruleId: "todo-coment", reason: "why" }), + /Unknown DebtLens rule "todo-coment"\. Did you mean "todo-comment"\?/, + ); + }); + + it("rejects empty reasons", () => { + assert.throws( + () => runSuppress({ ruleId: "todo-comment", reason: " " }), + /non-empty --reason is required/, + ); + }); + + it("emits directives the scanner actually honors", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-suppress-")); + try { + mkdirSync(join(dir, "src")); + const directive = runSuppress({ ruleId: "todo-comment", reason: "tracked in JIRA-123" }); + writeFileSync(join(dir, "src", "app.ts"), `${directive}// TODO fix later\nexport const value = 1;\n`); + + const result = await scan({ + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "info", + rules: ["todo-comment"], + thresholds: defaultConfig.thresholds, + maxFiles: defaultConfig.maxFiles, + }); + + assert.equal(result.summary.totalIssues, 0); + assert.equal(result.summary.filterStats?.suppressedByInline, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/config/mergeConfig.test.ts b/tests/config/mergeConfig.test.ts new file mode 100644 index 0000000..ef1c823 --- /dev/null +++ b/tests/config/mergeConfig.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { mergeConfig } from "../../src/config/mergeConfig.js"; +import { defaultConfig } from "../../src/config/defaults.js"; + +describe("mergeConfig", () => { + it("merges plugin thresholds after built-in defaults and before user config", () => { + const options = mergeConfig(".", { thresholds: { "from-config.max": 10, "shared.max": 20 } }, { + cwd: process.cwd(), + pluginThresholds: { "from-plugin.max": 5, "shared.max": 1 }, + thresholds: { "from-cli.max": 99 }, + }); + + assert.equal(options.thresholds["from-plugin.max"], 5); + assert.equal(options.thresholds["from-config.max"], 10); + assert.equal(options.thresholds["shared.max"], 20); + assert.equal(options.thresholds["from-cli.max"], 99); + assert.equal(options.thresholds["large-component.maxLines"], defaultConfig.thresholds["large-component.maxLines"]); + }); + + it("lets CLI thresholds override config and plugin values", () => { + const options = mergeConfig(".", { thresholds: { "shared.max": 20 } }, { + cwd: process.cwd(), + pluginThresholds: { "shared.max": 1 }, + thresholds: { "shared.max": 50 }, + }); + + assert.equal(options.thresholds["shared.max"], 50); + }); + + it("merges plugin vocabulary below user config groups", () => { + const options = mergeConfig(".", { vocabulary: { media: ["movie"], payments: ["invoice"] } }, { + cwd: process.cwd(), + pluginVocabulary: { media: ["film", "show"], logging: ["log", "trace"] }, + }); + + assert.deepEqual(options.vocabulary?.media, ["movie"]); + assert.deepEqual(options.vocabulary?.logging, ["log", "trace"]); + assert.deepEqual(options.vocabulary?.payments, ["invoice"]); + }); + + it("passes through valid ruleSeverities and ruleConfidenceFloors", () => { + const options = mergeConfig(".", { + ruleSeverities: { "naming-drift": "info" }, + ruleConfidenceFloors: { "prop-drilling": 0.8 }, + }, { cwd: process.cwd() }); + + assert.deepEqual(options.ruleSeverities, { "naming-drift": "info" }); + assert.deepEqual(options.ruleConfidenceFloors, { "prop-drilling": 0.8 }); + }); + + it("rejects invalid ruleSeverities values", () => { + assert.throws( + () => mergeConfig(".", { ruleSeverities: { "naming-drift": "loud" as never } }, { cwd: process.cwd() }), + /"ruleSeverities.naming-drift" must be one of info, low, medium, high/, + ); + }); + + it("rejects out-of-range ruleConfidenceFloors values", () => { + assert.throws( + () => mergeConfig(".", { ruleConfidenceFloors: { "prop-drilling": 1.5 } }, { cwd: process.cwd() }), + /"ruleConfidenceFloors.prop-drilling" must be a number between 0 and 1/, + ); + }); +}); diff --git a/tests/core/scan.test.ts b/tests/core/scan.test.ts index c833057..474ff72 100644 --- a/tests/core/scan.test.ts +++ b/tests/core/scan.test.ts @@ -68,6 +68,85 @@ describe("scan integration", () => { } }); + it("applies ruleSeverities overrides to reported issues and summary counts", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-scan-severities-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "app.ts"), "// TODO fix later\nexport const value = 1;\n"); + + const baseOptions = { + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "info" as const, + rules: ["todo-comment"], + thresholds: defaultConfig.thresholds, + maxFiles: defaultConfig.maxFiles, + }; + + const result = await scan({ ...baseOptions, ruleSeverities: { "todo-comment": "high" } }); + + assert.equal(result.summary.totalIssues, 1); + assert.equal(result.issues[0]?.severity, "high"); + assert.equal(result.summary.bySeverity.high, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("filters issues below a per-rule confidence floor and tracks filterStats", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-scan-confidence-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "app.ts"), "// TODO fix later\nexport const value = 1;\n"); + + const result = await scan({ + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "info", + rules: ["todo-comment"], + thresholds: defaultConfig.thresholds, + maxFiles: defaultConfig.maxFiles, + ruleConfidenceFloors: { "todo-comment": 1 }, + }); + + assert.equal(result.summary.totalIssues, 0); + assert.equal(result.summary.filterStats?.filteredByConfidenceFloor, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("warns on unknown rule ids in per-rule overrides with a suggestion", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-scan-unknown-rule-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "app.ts"), "export const value = 1;\n"); + + const result = await scan({ + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "info", + rules: ["todo-comment"], + thresholds: defaultConfig.thresholds, + maxFiles: defaultConfig.maxFiles, + ruleSeverities: { "todo-coment": "info" }, + ruleConfidenceFloors: { "prop-driling": 0.5 }, + }); + + const warnings = result.summary.warnings ?? []; + assert.ok(warnings.some((warning) => warning.includes('ruleSeverities: unknown rule "todo-coment" (did you mean "todo-comment"?)'))); + assert.ok(warnings.some((warning) => warning.includes('ruleConfidenceFloors: unknown rule "prop-driling" (did you mean "prop-drilling"?)'))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("keeps scanning outside git repos when respectGitignore is enabled", async () => { const dir = mkdtempSync(join(tmpdir(), "debtlens-scan-plain-")); try { diff --git a/tests/plugins/loadPlugins.test.ts b/tests/plugins/loadPlugins.test.ts index e824671..f982b67 100644 --- a/tests/plugins/loadPlugins.test.ts +++ b/tests/plugins/loadPlugins.test.ts @@ -43,7 +43,7 @@ describe("loadPlugins", () => { }); }); - it("loads a { rules } export and warns on unsupported vocabulary", async () => { + it("loads a { rules } export with vocabulary and thresholds", async () => { await withTempDir(async (dir) => { writeFileSync(join(dir, "plugin.mjs"), ` const rule = { @@ -54,12 +54,65 @@ const rule = { tags: [], detect: () => [], }; -export default { rules: [rule], vocabulary: { media: ["movie", "film"] } }; +export default { + rules: [rule], + thresholds: { "custom-rule.maxThings": 3 }, + vocabulary: { media: ["movie", "film"] }, +}; `); const result = await loadPlugins(dir, { plugins: ["./plugin.mjs"] }, builtInIds); assert.equal(result.detectors.length, 1); assert.equal(result.detectors[0]?.id, "custom-rule"); - assert.match(result.warnings[0] ?? "", /vocabulary export is not supported yet/); + assert.deepEqual(result.thresholds, { "custom-rule.maxThings": 3 }); + assert.deepEqual(result.vocabulary, { media: ["movie", "film"] }); + assert.deepEqual(result.warnings, []); + }); + }); + + it("rejects non-numeric plugin thresholds", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "plugin.mjs"), ` +export default { + rules: [], + thresholds: { "custom-rule.maxThings": "lots" }, +}; +`); + await assert.rejects( + loadPlugins(dir, { plugins: ["./plugin.mjs"] }, builtInIds), + /threshold "custom-rule.maxThings" must be a finite number/, + ); + }); + }); + + it("rejects malformed plugin vocabulary groups", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "plugin.mjs"), ` +export default { + rules: [], + vocabulary: { media: [] }, +}; +`); + await assert.rejects( + loadPlugins(dir, { plugins: ["./plugin.mjs"] }, builtInIds), + /vocabulary group "media" must be a non-empty array of strings/, + ); + }); + }); + + it("warns when a later plugin overrides an earlier plugin's threshold or vocabulary", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "one.mjs"), ` +export default { rules: [], thresholds: { "shared.max": 1 }, vocabulary: { media: ["movie"] } }; +`); + writeFileSync(join(dir, "two.mjs"), ` +export default { rules: [], thresholds: { "shared.max": 2 }, vocabulary: { media: ["film"] } }; +`); + const result = await loadPlugins(dir, { plugins: ["./one.mjs", "./two.mjs"] }, builtInIds); + assert.deepEqual(result.thresholds, { "shared.max": 2 }); + assert.deepEqual(result.vocabulary, { media: ["film"] }); + assert.equal(result.warnings.length, 2); + assert.match(result.warnings[0] ?? "", /threshold "shared.max" was already set by an earlier plugin/); + assert.match(result.warnings[1] ?? "", /vocabulary group "media" was already set by an earlier plugin/); }); });