Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <rule> # print rule docs, default thresholds, and false-positive guidance
debtlens suppress --rule <rule> --reason "<why>" # print a copy-paste inline suppression comment
debtlens scan [target]
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions docs/plugin-api-rfc.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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<string, string[]> }`
- `{ rules: Detector[], thresholds?: Record<string, number>, vocabulary?: Record<string, string[]> }`

`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

Expand Down Expand Up @@ -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).
19 changes: 17 additions & 2 deletions examples/plugin/no-console.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"],
},
};
129 changes: 129 additions & 0 deletions schema/debtlens.config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
42 changes: 37 additions & 5 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -317,6 +320,25 @@ program.command("explain")
}
});

program.command("suppress")
.description("Print a copy-paste inline suppression comment for a finding.")
.requiredOption("--rule <rule>", "rule id to suppress, e.g. todo-comment")
.requiredOption("--reason <text>", "why the finding is acceptable (required by the scanner)")
.option("--file", "emit a file-level directive instead of next-line")
.action((rawOptions: Record<string, unknown>) => {
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")
Expand Down Expand Up @@ -420,11 +442,17 @@ function parseConfidence(value: string): number {
return parsed;
}

interface PluginContribution {
detectors?: Detector[];
thresholds?: ScanThresholds;
vocabulary?: Record<string, string[]>;
}

async function loadConfiguredPlugins(
cwd: string,
rawOptions: Record<string, unknown>,
fileConfig: DebtLensConfig,
): Promise<Detector[] | undefined> {
): Promise<PluginContribution | undefined> {
if (!fileConfig.plugins?.length) return undefined;

const configPath = findConfigPath(cwd, rawOptions.config ? String(rawOptions.config) : undefined);
Expand All @@ -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(
Expand Down
Loading