diff --git a/CHANGELOG.md b/CHANGELOG.md index 053958d..4364d51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,35 @@ All notable changes to DebtLens are documented here. This project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **`debtlens explain `** command printing rule docs, default thresholds, and + false-positive guidance from `docs/rules.md` ([#145](https://github.com/ColumbusLabs/DebtLens/issues/145)). +- **Did-you-mean suggestions** for unknown rule ids in `--rules`, config `rules`, inline + suppression directives, and `debtlens explain` ([#151](https://github.com/ColumbusLabs/DebtLens/issues/151)). +- **`failOn` config field** to set the CI exit-code severity policy in + `debtlens.config.json`; the `--fail-on` CLI flag overrides it + ([#106](https://github.com/ColumbusLabs/DebtLens/issues/106)). +- **`pluginApiVersion` and `plugins` config fields** with fail-fast runtime validation + against the supported plugin API version + ([#69](https://github.com/ColumbusLabs/DebtLens/issues/69)). The plugin API version is + an integer bumped only on breaking `Detector`/`DetectorContext` changes; bumps are + documented here and in `docs/plugin-api-rfc.md`. +- **Plugin loader** for local ESM rule plugins per the plugin API RFC: detectors are + validated against the built-in `Detector` contract, rule id collisions fail fast, and + paths cannot escape the config directory + ([#68](https://github.com/ColumbusLabs/DebtLens/issues/68)). +- **`DEBTLENS_DISABLE_PLUGINS=1`** environment escape hatch for CI pipelines scanning + untrusted repositories; built-in rules still run + ([#71](https://github.com/ColumbusLabs/DebtLens/issues/71)). +- **Reference plugin** in `examples/plugin/` (no-console rule) with CI integration + coverage ([#72](https://github.com/ColumbusLabs/DebtLens/issues/72)). +- **`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)). + ## [0.3.0] - 2026-06-09 ### Added diff --git a/README.md b/README.md index d8df72b..cfbe4d3 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ debtlens adopt # adoption report (dry run; recommends minSeverity) 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 scan [target] ``` @@ -305,6 +306,32 @@ Explicit `rules` in config override the pack. Use `debtlens packs` to list prese } ``` +### Plugins + +Ship custom rules as local ESM modules without forking the CLI. List them in config with +the plugin API version, then select them like built-in rules: + +```json +{ + "$schema": "https://raw.githubusercontent.com/ColumbusLabs/DebtLens/main/schema/debtlens.config.schema.json", + "pluginApiVersion": 1, + "plugins": ["./debtlens-rules/no-console.mjs"], + "include": ["src/**/*.{ts,tsx,js,jsx}"] +} +``` + +Plugin authors import types from the published `debtlens/plugin` entry point: + +```ts +import type { Detector, DetectorContext } from "debtlens/plugin"; +``` + +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 +CI pipelines scanning untrusted repos can set `DEBTLENS_DISABLE_PLUGINS=1` to skip +plugin loading entirely (see [`SECURITY.md`](./SECURITY.md)). + ## Output formats Terminal output is designed for local development. JSON is designed for integrations. Markdown is designed for release notes and maintainer handoffs. `pr-comment` is compact Markdown grouped by file for GitHub pull request comments. SARIF (2.1.0) is designed for GitHub code scanning and other security/quality dashboards. diff --git a/SECURITY.md b/SECURITY.md index 043348b..a5a8e9b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -10,3 +10,15 @@ Security goals: - Avoid executing scanned code. - Avoid loading arbitrary project config as executable JavaScript. - Prefer JSON config until a safe plugin model exists. + +## Plugins + +Config-listed plugins (`plugins` in `debtlens.config.json`) are local ESM modules that +execute with the CLI's privileges. Treat them like any other code in the repository: +only enable them in trusted pipelines. Plugin paths must stay within the config file's +directory tree, and no code is loaded from config values other than the explicit +`plugins` list (see [`docs/plugin-api-rfc.md`](./docs/plugin-api-rfc.md)). + +CI environments scanning untrusted repositories can set `DEBTLENS_DISABLE_PLUGINS=1` +to skip plugin loading entirely; built-in rules still run and a single note is written +to stderr when configured plugins are skipped. diff --git a/docs/next-phase-plan.md b/docs/next-phase-plan.md new file mode 100644 index 0000000..84dd05b --- /dev/null +++ b/docs/next-phase-plan.md @@ -0,0 +1,154 @@ +# Next Phase Plan: Plugin API + CLI Quick Wins + +Status: **Implemented** — all eight issues landed on this branch as five sequential +commits matching the PR breakdown below. +Date: 2026-06-10 + +## Context + +v0.3 ("Maintainer workflow integrations") is shipped. Per [`ROADMAP.md`](../ROADMAP.md), +the headline item for v0.4 is the **plugin API for third-party rules**, which already has +an accepted design in [`docs/plugin-api-rfc.md`](./plugin-api-rfc.md) but no implementation: +`plugins` and `pluginApiVersion` do not exist yet in the config schema, and all detectors +are hardcoded in [`src/detectors/index.ts`](../src/detectors/index.ts). + +This plan selects **eight open issues** in two tracks: the plugin API vertical (five +issues that together ship the RFC end to end) and three small, independent CLI/config +quick wins that improve day-to-day DX while the larger work lands. + +## Selected issues + +### Track A — Plugin API (roadmap v0.4 centerpiece) + +| Order | Issue | Title | Difficulty | +| --- | --- | --- | --- | +| A1 | [#69](https://github.com/ColumbusLabs/DebtLens/issues/69) | Add `pluginApiVersion` to config schema and runtime validation | Small | +| A2 | [#68](https://github.com/ColumbusLabs/DebtLens/issues/68) | Implement plugin loader prototype | Large | +| A3 | [#71](https://github.com/ColumbusLabs/DebtLens/issues/71) | Add `DEBTLENS_DISABLE_PLUGINS` CI escape hatch | Small | +| A4 | [#72](https://github.com/ColumbusLabs/DebtLens/issues/72) | Add example plugin repo fixture and integration test | Medium | +| A5 | [#70](https://github.com/ColumbusLabs/DebtLens/issues/70) | Export `debtlens/plugin` TypeScript types entry point | Medium | + +**Why this cluster:** these five issues are mutually dependent slices of one feature, all +labeled `type: rfc`, and the RFC resolves every design question in advance (config shape, +loading model, versioning, security constraints). Shipping them together moves the roadmap's +single biggest v0.4 commitment from "designed" to "done" and unblocks the follow-on plugin +issues (#73 plugin thresholds, #74 plugin vocabulary, #165 policy packs). + +### Track B — CLI/config quick wins (independent, low risk) + +| Order | Issue | Title | Difficulty | +| --- | --- | --- | --- | +| B1 | [#151](https://github.com/ColumbusLabs/DebtLens/issues/151) | Suggest did-you-mean for unknown rule ids | Small | +| B2 | [#145](https://github.com/ColumbusLabs/DebtLens/issues/145) | Add `debtlens explain` command for rule documentation | Small | +| B3 | [#106](https://github.com/ColumbusLabs/DebtLens/issues/106) | Add `failOn` severity to config file | Small | + +**Why these:** all three are `good first issue`-class, touch isolated code paths, and +two of them (#151, #145) become more valuable once plugins exist — did-you-mean and +`explain` should operate over the merged (built-in + plugin) registry, so building them +in the same phase keeps the registry abstraction honest. + +**Deliberately deferred:** performance work (#138–#142) until the plugin loader settles the +detector registry shape; Python pack (#92–#96) since it depends on the language-pack +interface spike; new core rules (#85–#91) which are independent and parallelizable by +other contributors. + +## Execution plan + +### A1 — `pluginApiVersion` (#69) + +- Add `pluginApiVersion` (integer) and `plugins` (string array) to the JSON schema in + [`src/config/schema.ts`](../src/config/schema.ts) and to `DebtLensConfig` in + [`src/config/loadConfig.ts`](../src/config/loadConfig.ts). +- Export `DEBTLENS_PLUGIN_API_VERSION = 1` constant from a new `src/plugins/version.ts`. +- Validate at config load: mismatch throws with an upgrade message naming both versions. +- Update the schema drift test (`tests/config/schema.test.ts`) and CHANGELOG with the bump policy. + +### A2 — Plugin loader (#68) + +- New `src/plugins/loadPlugins.ts`: + - Resolve each `plugins[]` path relative to the config file directory; reject paths + escaping the repo root (per RFC security section). + - Dynamic `import()` of ESM modules; accept default export of a single `Detector` or + `{ rules: Detector[], vocabulary? }`. + - Validate detector shape (id, name, description, defaultSeverity, tags, detect) and + error on id collisions with built-ins or other plugins. +- Merge plugin detectors into the registry consumed by `scan()` in + [`src/core/scan.ts`](../src/core/scan.ts); explicit `rules` selection works unchanged. +- Tests: happy path, invalid export shape, id collision, path traversal rejection + (`tests/core/scan.test.ts`, new `tests/plugins/loadPlugins.test.ts`). + +### A3 — `DEBTLENS_DISABLE_PLUGINS` (#71) + +- In the loader entry point: when env var is `1`, skip loading and emit one stderr note + if `plugins` is configured. Built-in rules unaffected. +- Document in `SECURITY.md` and the RFC (the RFC already reserves this flag). +- CLI test asserting scan succeeds with plugins configured but disabled. + +### A4 — Example plugin + integration test (#72) + +- Add `examples/plugin/no-console.mjs` (the RFC's minimal example) plus a sample + `debtlens.config.json` enabling it. +- Integration test scans a small fixture and asserts the plugin finding appears in JSON + output; wire into the default `npm test` run. +- Link the example from the RFC and README. + +### A5 — `debtlens/plugin` types entry (#70) + +- Add a `./plugin` subpath export in `package.json` exposing `Detector`, + `DetectorContext`, `DebtIssue`, and `Severity` from [`src/core/types.ts`](../src/core/types.ts). +- Convert the example plugin (or add a sibling) to a type-checked `.ts` variant that + imports only from the published entry; verify with `npm run typecheck`. +- Document in the RFC and README. + +### B1 — Did-you-mean for rule ids (#151) + +- Small Levenshtein helper in `src/utils/` (no new dependency). +- Apply where `--rules`, config `rules`, and suppression directives reject unknown ids + (CLI parse in [`src/cli/index.ts`](../src/cli/index.ts), + [`src/core/suppressions.ts`](../src/core/suppressions.ts)). +- Match against the merged registry ids so plugin rules are suggested too. +- Acceptance: `todo-comments` suggests `todo-comment` (`tests/cli/`). + +### B2 — `debtlens explain` (#145) + +- New `explain ` command in [`src/cli/index.ts`](../src/cli/index.ts) rendering + the matching section of [`docs/rules.md`](./rules.md) plus default severity, tags, and + thresholds from the detector registry. +- Unknown rule id exits non-zero and reuses the B1 did-you-mean helper. +- Tests in `tests/cli/`. + +### B3 — `failOn` in config (#106) + +- Add `failOn` to the config schema and `mergeConfig` precedence + ([`src/config/mergeConfig.ts`](../src/config/mergeConfig.ts)): CLI `--fail-on` overrides + config value, matching the existing `failOnConfidence` pattern. +- Tests: config-only `failOn` gates exit code (`tests/cli/scan.test.ts`); schema drift + test updated (`tests/config/schema.test.ts`). + +## Sequencing and PR breakdown + +``` +PR 1: B1 + B2 (did-you-mean helper, explain command — explain reuses the helper) +PR 2: B3 (failOn config — isolated) +PR 3: A1 (schema + version constant — small, reviewable alone) +PR 4: A2 + A3 (loader + escape hatch — the escape hatch is part of the loader's entry) +PR 5: A4 + A5 (example plugin + types entry — the typed example validates the entry point) +``` + +Tracks A and B are independent; PRs 1–3 can land in any order. PR 4 depends on PR 3, +and PR 5 depends on PR 4. + +## Validation + +- `npm test` (full suite) on every PR; targeted suites per issue's stated test command. +- `npm run typecheck` for PR 5. +- Schema drift tests guard config changes in PRs 2–3. +- The calibration fixtures (`tests/fixtures/quality/`) guard against detector behavior + drift — no detector logic changes in this phase, so counts must stay identical. + +## Definition of done + +- All eight issues closed with tests matching their stated acceptance criteria. +- Plugin RFC status updated from Draft to Shipped, with follow-ons (#73, #74, #165) noted. +- CHANGELOG entries for the plugin API (with `pluginApiVersion` bump policy), `explain`, + did-you-mean, and config `failOn`. diff --git a/docs/plugin-api-rfc.md b/docs/plugin-api-rfc.md index 2a2bfdb..9ff4b24 100644 --- a/docs/plugin-api-rfc.md +++ b/docs/plugin-api-rfc.md @@ -1,6 +1,6 @@ # Plugin API RFC -Status: **Draft** — design only; no loader shipped yet. Target: v0.4 ([`ROADMAP.md`](../ROADMAP.md)). +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)). ## Problem @@ -90,7 +90,7 @@ Align with [`SECURITY.md`](../SECURITY.md): - **No network** during plugin load. - **No arbitrary code** from config values — only explicit `plugins` paths. - Paths must stay within the repository (reject `..` traversal outside repo root). -- CI environments may set `DEBTLENS_DISABLE_PLUGINS=1` to skip loading (future flag). +- CI environments may set `DEBTLENS_DISABLE_PLUGINS=1` to skip loading entirely; built-in rules still run and a single stderr note is emitted when configured plugins are skipped. Untrusted repos: treat plugins like any local code — only enable in trusted pipelines. @@ -98,7 +98,7 @@ Untrusted repos: treat plugins like any local code — only enable in trusted pi ```js // debtlens-rules/no-console.mjs -/** @type {import("debtlens").Detector} */ +/** @type {import("debtlens/plugin").Detector} */ export const noConsoleDetector = { id: "no-console", name: "No console", @@ -130,15 +130,15 @@ export const noConsoleDetector = { export default { rules: [noConsoleDetector] }; ``` -(Pseudocode — types would ship from a future `debtlens/plugin` entry point.) +A runnable version of this plugin lives in [`examples/plugin/`](../examples/plugin/), and types ship from the published `debtlens/plugin` entry point (`import type { Detector } from "debtlens/plugin"`). ## Implementation phases -1. **RFC merged** (this document) — no runtime change -2. **Loader prototype** — `import()` + validation behind feature flag -3. **Schema + docs** — `plugins` / `pluginApiVersion` in JSON schema -4. **Example repo** — sample plugin + integration test -5. **Stable export** — document supported API in [`docs/architecture.md`](./architecture.md) +1. **RFC merged** (this document) — no runtime change ✅ +2. **Loader prototype** — `import()` + validation ✅ ([#68](https://github.com/ColumbusLabs/DebtLens/issues/68)) +3. **Schema + docs** — `plugins` / `pluginApiVersion` in JSON schema ✅ ([#69](https://github.com/ColumbusLabs/DebtLens/issues/69)) +4. **Example plugin** — [`examples/plugin/`](../examples/plugin/) + integration test ✅ ([#72](https://github.com/ColumbusLabs/DebtLens/issues/72)) +5. **Stable export** — `debtlens/plugin` types entry point ✅ ([#70](https://github.com/ColumbusLabs/DebtLens/issues/70)) ## Open questions diff --git a/examples/plugin/debtlens.config.json b/examples/plugin/debtlens.config.json new file mode 100644 index 0000000..e82d4e6 --- /dev/null +++ b/examples/plugin/debtlens.config.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/ColumbusLabs/DebtLens/main/schema/debtlens.config.schema.json", + "pluginApiVersion": 1, + "plugins": ["./no-console.mjs"], + "include": ["src/**/*.{ts,tsx,js,jsx}"] +} diff --git a/examples/plugin/no-console.mjs b/examples/plugin/no-console.mjs new file mode 100644 index 0000000..d06f31c --- /dev/null +++ b/examples/plugin/no-console.mjs @@ -0,0 +1,37 @@ +// Reference DebtLens plugin: flags console.log calls in production source. +// Run it from this directory: npx debtlens scan src --rules no-console +// See docs/plugin-api-rfc.md for the plugin contract. + +/** @type {import("debtlens/plugin").Detector} */ +const noConsoleDetector = { + id: "no-console", + name: "No console", + description: "Flags console.log in production source.", + defaultSeverity: "low", + tags: ["hygiene"], + detect(context) { + const issues = []; + for (const file of context.files) { + const lines = file.content.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + if (!lines[index].includes("console.log")) continue; + 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 }, + evidence: [lines[index].trim()], + suggestion: "Remove debug logging or route through a logger.", + tags: ["hygiene"], + }); + } + } + return issues; + }, +}; + +export default { rules: [noConsoleDetector] }; diff --git a/examples/plugin/src/app.ts b/examples/plugin/src/app.ts new file mode 100644 index 0000000..6f5247b --- /dev/null +++ b/examples/plugin/src/app.ts @@ -0,0 +1,7 @@ +export function startApp(): void { + console.log("booting app"); +} + +export function shutdownApp(): void { + // Intentionally quiet: no debug logging here. +} diff --git a/package.json b/package.json index b7debf9..8627a59 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,13 @@ "bin": { "debtlens": "dist/cli/index.js" }, + "exports": { + "./plugin": { + "types": "./dist/plugin.d.ts", + "import": "./dist/plugin.js" + }, + "./package.json": "./package.json" + }, "files": [ "dist", "schema", diff --git a/schema/debtlens.config.schema.json b/schema/debtlens.config.schema.json index 48bb9f4..0415f99 100644 --- a/schema/debtlens.config.schema.json +++ b/schema/debtlens.config.schema.json @@ -45,18 +45,38 @@ "type": "array", "uniqueItems": true, "items": { - "enum": [ - "large-component", - "state-sprawl", - "effect-complexity", - "duplicate-logic", - "dead-abstraction", - "prop-drilling", - "todo-comment", - "naming-drift" + "anyOf": [ + { + "enum": [ + "large-component", + "state-sprawl", + "effect-complexity", + "duplicate-logic", + "dead-abstraction", + "prop-drilling", + "todo-comment", + "naming-drift" + ] + }, + { + "type": "string", + "description": "A plugin-provided rule id." + } ] }, - "description": "Rule ids to run. Omit to run all rules." + "description": "Rule ids to run. Omit to run all rules. May include plugin rule ids when plugins are configured." + }, + "pluginApiVersion": { + "type": "integer", + "minimum": 1, + "description": "Plugin API version this config targets; must match the DebtLens runtime version." + }, + "plugins": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths to local ESM plugin modules, resolved relative to the config file directory." }, "maxFiles": { "type": "integer", @@ -198,6 +218,15 @@ }, "additionalProperties": false }, + "failOn": { + "enum": [ + "info", + "low", + "medium", + "high" + ], + "description": "Exit with code 1 when any reported issue meets this severity. The --fail-on CLI flag overrides this." + }, "failOnConfidence": { "type": "number", "minimum": 0, diff --git a/src/cli/explain.ts b/src/cli/explain.ts new file mode 100644 index 0000000..bf728f1 --- /dev/null +++ b/src/cli/explain.ts @@ -0,0 +1,71 @@ +import { readFileSync } from "node:fs"; +import { defaultConfig } from "../config/defaults.js"; +import { allDetectors } from "../detectors/index.js"; +import { suggestClosest } from "../utils/didYouMean.js"; + +/** + * Render rule documentation for `debtlens explain `: registry metadata, + * default thresholds, and the matching section of docs/rules.md when available. + */ +export function runExplain(ruleId: string): string { + const normalized = 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 "${ruleId}".${hint} Run "debtlens rules" to list available rules.`); + } + + const lines = [ + `${detector.name} [${detector.id}]`, + "", + detector.description, + "", + `Default severity: ${detector.defaultSeverity}`, + `Tags: ${detector.tags.join(", ")}`, + ]; + + const thresholds = Object.entries(defaultConfig.thresholds) + .filter(([key]) => key.startsWith(`${detector.id}.`)); + if (thresholds.length > 0) { + lines.push("", "Default thresholds:"); + for (const [key, value] of thresholds) { + lines.push(` ${key}: ${value}`); + } + } + + const docsSection = readRuleDocsSection(detector.id); + if (docsSection) { + lines.push("", docsSection); + } + + return `${lines.join("\n")}\n`; +} + +/** + * Extract the `## \`\`` section from docs/rules.md. The docs directory + * is published with the npm package, two levels above this module in both the + * src and dist layouts. Returns undefined when the docs are unavailable. + */ +function readRuleDocsSection(ruleId: string): string | undefined { + let content: string; + try { + content = readFileSync(new URL("../../docs/rules.md", import.meta.url), "utf8"); + } catch { + return undefined; + } + + const lines = content.split(/\r?\n/); + const headingIndex = lines.findIndex((line) => line.trim() === `## \`${ruleId}\``); + if (headingIndex === -1) return undefined; + + let end = lines.length; + for (let index = headingIndex + 1; index < lines.length; index += 1) { + if (lines[index]!.startsWith("## ")) { + end = index; + break; + } + } + + return lines.slice(headingIndex + 1, end).join("\n").trim(); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 624d61d..8bd78c7 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { Command } from "commander"; -import { loadConfig } from "../config/loadConfig.js"; +import { findConfigPath, loadConfig } from "../config/loadConfig.js"; import { mergeConfig } from "../config/mergeConfig.js"; import { listRulePacks, RULE_PACK_IDS } from "../config/packs.js"; import { resolveWorkspacePackage } from "../config/workspaces.js"; @@ -10,10 +10,12 @@ 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, OutputFormat, ScanOptions, ScanResult, Severity } from "../core/types.js"; +import type { DebtIssue, DebtLensConfig, Detector, OutputFormat, ScanOptions, ScanResult, 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 { runDoctor } from "./doctor.js"; import { runAdopt } from "./adopt.js"; @@ -58,8 +60,9 @@ 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 minSeverity = parseSeverity(String(rawOptions.minSeverity ?? "low"), "low"); - const failOn = rawOptions.failOn ? parseSeverity(String(rawOptions.failOn), "high") : undefined; + const failOn = resolveFailOn(rawOptions, fileConfig); const failOnConfidence = resolveFailOnConfidence(rawOptions, fileConfig); let changedFiles: string[] | undefined; @@ -105,6 +108,7 @@ program.command("scan") changedFiles, fileContents, profile: rawOptions.profile === true, + pluginDetectors, }); if (rawOptions.writeBaseline && rawOptions.baseline) { @@ -300,6 +304,19 @@ program.command("rules") } }); +program.command("explain") + .description("Print rule documentation, default thresholds, and false-positive guidance.") + .argument("", "rule id, e.g. prop-drilling") + .action((rule: string) => { + try { + process.stdout.write(runExplain(rule)); + } 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") @@ -403,6 +420,35 @@ function parseConfidence(value: string): number { return parsed; } +async function loadConfiguredPlugins( + cwd: string, + rawOptions: Record, + fileConfig: DebtLensConfig, +): Promise { + if (!fileConfig.plugins?.length) return undefined; + + const configPath = findConfigPath(cwd, rawOptions.config ? String(rawOptions.config) : undefined); + const configDir = configPath ? dirname(configPath) : cwd; + const loaded = await loadPlugins(configDir, fileConfig, new Set(detectorIds)); + for (const warning of loaded.warnings) { + process.stderr.write(`DebtLens: ${warning}\n`); + } + return loaded.detectors.length > 0 ? loaded.detectors : undefined; +} + +function resolveFailOn( + rawOptions: Record, + fileConfig: DebtLensConfig, +): Severity | undefined { + if (rawOptions.failOn) { + return parseSeverity(String(rawOptions.failOn), "high"); + } + if (fileConfig.failOn !== undefined) { + return parseSeverity(String(fileConfig.failOn), "high"); + } + return undefined; +} + function resolveFailOnConfidence( rawOptions: Record, fileConfig: DebtLensConfig, diff --git a/src/config/defaults.ts b/src/config/defaults.ts index bd21ba3..8704941 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/loadConfig.ts b/src/config/loadConfig.ts index 128427f..9e2fc81 100644 --- a/src/config/loadConfig.ts +++ b/src/config/loadConfig.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; +import { DEBTLENS_PLUGIN_API_VERSION } from "../plugins/version.js"; import type { DebtLensConfig } from "../core/types.js"; const configNames = [ @@ -22,11 +23,30 @@ export function loadConfig(cwd: string, explicitPath?: string): DebtLensConfig { return {}; } + let config: DebtLensConfig; try { const raw = readFileSync(configPath, "utf8"); - return JSON.parse(raw) as DebtLensConfig; + config = JSON.parse(raw) as DebtLensConfig; } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Could not read DebtLens config at ${configPath}: ${message}`); } + + validatePluginApiVersion(config, configPath); + return config; +} + +function validatePluginApiVersion(config: DebtLensConfig, configPath: string): void { + if (config.plugins?.length && config.pluginApiVersion === undefined) { + throw new Error( + `${configPath}: "plugins" requires "pluginApiVersion": ${DEBTLENS_PLUGIN_API_VERSION} so DebtLens can fail fast on incompatible plugin APIs.`, + ); + } + + if (config.pluginApiVersion !== undefined && config.pluginApiVersion !== DEBTLENS_PLUGIN_API_VERSION) { + throw new Error( + `${configPath}: pluginApiVersion ${config.pluginApiVersion} is not supported by this DebtLens release ` + + `(supported: ${DEBTLENS_PLUGIN_API_VERSION}). Upgrade DebtLens or adjust the config; see docs/plugin-api-rfc.md.`, + ); + } } diff --git a/src/config/mergeConfig.ts b/src/config/mergeConfig.ts index 1471e69..4080d8a 100644 --- a/src/config/mergeConfig.ts +++ b/src/config/mergeConfig.ts @@ -52,5 +52,6 @@ export function mergeConfig(target: string, fileConfig: DebtLensConfig, cliOptio changedFiles: cliOptions.changedFiles, fileContents: cliOptions.fileContents, profile: cliOptions.profile, + pluginDetectors: cliOptions.pluginDetectors, }; } diff --git a/src/config/schema.ts b/src/config/schema.ts index d757ca3..6db3d16 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -46,8 +46,23 @@ export function buildConfigSchema(): Record { rules: { type: "array", uniqueItems: true, - items: { enum: [...detectorIds] }, - description: "Rule ids to run. Omit to run all rules.", + items: { + anyOf: [ + { enum: [...detectorIds] }, + { type: "string", description: "A plugin-provided rule id." }, + ], + }, + description: "Rule ids to run. Omit to run all rules. May include plugin rule ids when plugins are configured.", + }, + pluginApiVersion: { + type: "integer", + minimum: 1, + description: "Plugin API version this config targets; must match the DebtLens runtime version.", + }, + plugins: { + type: "array", + items: { type: "string" }, + description: "Paths to local ESM plugin modules, resolved relative to the config file directory.", }, maxFiles: { type: "integer", @@ -125,6 +140,10 @@ export function buildConfigSchema(): Record { }, additionalProperties: false, }, + failOn: { + enum: [...severities], + description: "Exit with code 1 when any reported issue meets this severity. The --fail-on CLI flag overrides this.", + }, failOnConfidence: { type: "number", minimum: 0, diff --git a/src/core/scan.ts b/src/core/scan.ts index c5c40da..1a8df52 100644 --- a/src/core/scan.ts +++ b/src/core/scan.ts @@ -5,6 +5,7 @@ import { allDetectors } from "../detectors/index.js"; import { canonicalize, resolveFilePaths } from "./resolveFiles.js"; import { compareSeverityDesc, meetsMinSeverity } from "./severity.js"; import { applyInlineSuppressions } from "./suppressions.js"; +import { suggestClosest } from "../utils/didYouMean.js"; import type { DebtIssue, Detector, ScanOptions, ScanResult, SourceFileInfo } from "./types.js"; export async function scan(options: ScanOptions): Promise { @@ -37,7 +38,8 @@ export async function scan(options: ScanOptions): Promise { }); } - const detectors = selectDetectors(options.rules); + const registry = [...allDetectors, ...(options.pluginDetectors ?? [])]; + const detectors = selectDetectors(registry, options.rules); let issues: DebtIssue[] = []; const warnings: string[] = []; let filteredByMinSeverity = 0; @@ -74,7 +76,7 @@ export async function scan(options: ScanOptions): Promise { return (a.location?.startLine ?? 0) - (b.location?.startLine ?? 0); }); - const validRuleIds = new Set(allDetectors.map((detector) => detector.id)); + const validRuleIds = new Set(registry.map((detector) => detector.id)); const suppression = applyInlineSuppressions(issues, files, validRuleIds); issues = suppression.issues; for (const warning of suppression.warnings) { @@ -124,17 +126,22 @@ function getContentOverride(options: ScanOptions, absolutePath: string): string return options.fileContents[canonicalize(absolutePath)] ?? options.fileContents[absolutePath]; } -function selectDetectors(ruleIds: string[] | undefined): Detector[] { +function selectDetectors(registry: Detector[], ruleIds: string[] | undefined): Detector[] { if (!ruleIds || ruleIds.length === 0) { - return allDetectors; + return registry; } const requested = new Set(ruleIds); - const selected = allDetectors.filter((detector) => requested.has(detector.id)); - const missing = [...requested].filter((ruleId) => !allDetectors.some((detector) => detector.id === ruleId)); + const selected = registry.filter((detector) => requested.has(detector.id)); + const missing = [...requested].filter((ruleId) => !registry.some((detector) => detector.id === ruleId)); if (missing.length > 0) { - throw new Error(`Unknown DebtLens rule(s): ${missing.join(", ")}`); + const knownIds = registry.map((detector) => detector.id); + const described = missing.map((ruleId) => { + const suggestion = suggestClosest(ruleId, knownIds); + return suggestion ? `${ruleId} (did you mean "${suggestion}"?)` : ruleId; + }); + throw new Error(`Unknown DebtLens rule(s): ${described.join(", ")}`); } return selected; diff --git a/src/core/suppressions.ts b/src/core/suppressions.ts index b6db22b..fa0f35b 100644 --- a/src/core/suppressions.ts +++ b/src/core/suppressions.ts @@ -1,3 +1,4 @@ +import { suggestClosest } from "../utils/didYouMean.js"; import type { DebtIssue, SourceFileInfo } from "./types.js"; const disableNextLinePattern = /debtlens-disable-next-line\s+([a-z0-9-]+)(?:\s+--\s+(.+))?/i; @@ -86,7 +87,9 @@ function registerSuppression( const normalizedRuleId = ruleId.toLowerCase(); if (!validRuleIds.has(normalizedRuleId)) { - addWarning(warnings, `${file}: unknown suppression rule "${normalizedRuleId}"`); + const suggestion = suggestClosest(normalizedRuleId, [...validRuleIds]); + const hint = suggestion ? ` (did you mean "${suggestion}"?)` : ""; + addWarning(warnings, `${file}: unknown suppression rule "${normalizedRuleId}"${hint}`); return; } diff --git a/src/core/types.ts b/src/core/types.ts index d56c806..1017039 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -66,6 +66,12 @@ export interface DebtLensConfig { /** Built-in labels to disable (e.g. "todo marker"). */ disableDefaults?: string[]; }; + /** Plugin API version this config targets; must match the DebtLens runtime version. */ + pluginApiVersion?: number; + /** Paths to local ESM plugin modules, resolved relative to the config file directory. */ + plugins?: string[]; + /** Exit with code 1 when any reported issue meets this severity. CLI `--fail-on` overrides. */ + failOn?: Severity; /** Exit with code 1 only when a reported issue meets `--fail-on` and this confidence floor. */ failOnConfidence?: number; } @@ -97,6 +103,8 @@ export interface ScanOptions { todoCommentMarkers?: Array<{ regex: RegExp; severity: Severity; label: string }>; /** When true, collect per-rule timing in `summary.profile`. */ profile?: boolean; + /** Detectors contributed by config-loaded plugins, merged after built-in rules. */ + pluginDetectors?: Detector[]; } export interface CliOptions { @@ -118,6 +126,7 @@ export interface CliOptions { changedFiles?: string[]; fileContents?: Record; profile?: boolean; + pluginDetectors?: Detector[]; } export interface DetectorContext { diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..de7a262 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,18 @@ +/** + * Public entry point for DebtLens plugin authors, published as `debtlens/plugin`. + * Import types from here instead of internal paths; this surface is versioned by + * DEBTLENS_PLUGIN_API_VERSION (see docs/plugin-api-rfc.md). + * + * @example + * import type { Detector, DetectorContext } from "debtlens/plugin"; + */ +export type { + DebtIssue, + Detector, + DetectorContext, + IssueLocation, + ScanOptions, + Severity, + SourceFileInfo, +} from "./core/types.js"; +export { DEBTLENS_PLUGIN_API_VERSION } from "./plugins/version.js"; diff --git a/src/plugins/loadPlugins.ts b/src/plugins/loadPlugins.ts new file mode 100644 index 0000000..3075189 --- /dev/null +++ b/src/plugins/loadPlugins.ts @@ -0,0 +1,135 @@ +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"; + +export interface PluginLoadResult { + detectors: Detector[]; + warnings: string[]; +} + +const RULE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +export function pluginsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + return env.DEBTLENS_DISABLE_PLUGINS === "1"; +} + +/** + * 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. + * See docs/plugin-api-rfc.md for the full loading model. + */ +export async function loadPlugins( + configDir: string, + config: DebtLensConfig, + builtInRuleIds: ReadonlySet, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const warnings: string[] = []; + const pluginPaths = config.plugins ?? []; + if (pluginPaths.length === 0) { + return { detectors: [], warnings }; + } + + if (pluginsDisabled(env)) { + warnings.push("plugins configured but skipped because DEBTLENS_DISABLE_PLUGINS=1"); + return { detectors: [], warnings }; + } + + const detectors: Detector[] = []; + const seenRuleIds = new Set(builtInRuleIds); + + for (const pluginPath of pluginPaths) { + const absolutePath = resolve(configDir, pluginPath); + const relativeToConfig = relative(configDir, absolutePath); + if (relativeToConfig.startsWith("..") || isAbsolute(relativeToConfig)) { + throw new Error( + `Plugin path "${pluginPath}" resolves outside the config directory; plugins must live within the repository.`, + ); + } + if (!existsSync(absolutePath)) { + throw new Error(`Plugin module not found: ${absolutePath}`); + } + + let moduleExports: { default?: unknown }; + try { + moduleExports = await import(pathToFileURL(absolutePath).href) as { default?: unknown }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + 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`); + } + + for (const candidate of rules) { + const detector = validateDetector(candidate, pluginPath); + if (seenRuleIds.has(detector.id)) { + throw new Error( + `Plugin "${pluginPath}" exports rule id "${detector.id}", which collides with an existing rule.`, + ); + } + seenRuleIds.add(detector.id); + detectors.push(detector); + } + } + + return { detectors, warnings }; +} + +function normalizePluginExport( + exported: unknown, + pluginPath: string, +): { rules: unknown[]; vocabulary?: Record } { + if (exported && typeof exported === "object" && "rules" in exported) { + const shaped = exported as { rules: unknown; vocabulary?: Record }; + if (!Array.isArray(shaped.rules)) { + throw new Error(`Plugin "${pluginPath}" exports "rules" that is not an array.`); + } + return { rules: shaped.rules, vocabulary: shaped.vocabulary }; + } + + if (exported && typeof exported === "object") { + return { rules: [exported] }; + } + + throw new Error( + `Plugin "${pluginPath}" must default-export a Detector or { rules: Detector[] }; see docs/plugin-api-rfc.md.`, + ); +} + +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.`); + } + + const detector = candidate as Partial; + const describe = (problem: string) => + new Error(`Plugin "${pluginPath}" rule ${JSON.stringify(detector.id ?? "")}: ${problem}`); + + if (typeof detector.id !== "string" || !RULE_ID_PATTERN.test(detector.id)) { + throw describe("\"id\" must be a lowercase kebab-case string."); + } + if (typeof detector.name !== "string" || detector.name.length === 0) { + throw describe("\"name\" must be a non-empty string."); + } + if (typeof detector.description !== "string" || detector.description.length === 0) { + throw describe("\"description\" must be a non-empty string."); + } + if (typeof detector.defaultSeverity !== "string" || !isSeverity(detector.defaultSeverity)) { + throw describe("\"defaultSeverity\" must be one of info, low, medium, high."); + } + if (!Array.isArray(detector.tags) || detector.tags.some((tag) => typeof tag !== "string")) { + throw describe("\"tags\" must be an array of strings."); + } + if (typeof detector.detect !== "function") { + throw describe("\"detect\" must be a function."); + } + + return detector as Detector; +} diff --git a/src/plugins/version.ts b/src/plugins/version.ts new file mode 100644 index 0000000..4a994d9 --- /dev/null +++ b/src/plugins/version.ts @@ -0,0 +1,6 @@ +/** + * Plugin API version supported by this DebtLens release. Bumped on breaking + * changes to the `Detector` / `DetectorContext` contract; the bump policy is + * documented in CHANGELOG.md and docs/plugin-api-rfc.md. + */ +export const DEBTLENS_PLUGIN_API_VERSION = 1; diff --git a/src/utils/didYouMean.ts b/src/utils/didYouMean.ts new file mode 100644 index 0000000..df752fe --- /dev/null +++ b/src/utils/didYouMean.ts @@ -0,0 +1,42 @@ +/** + * Suggest the closest candidate for a mistyped identifier, or undefined when + * nothing is close enough to be a plausible typo. + */ +export function suggestClosest(input: string, candidates: readonly string[]): string | undefined { + const normalized = input.toLowerCase(); + const maxDistance = Math.max(2, Math.floor(normalized.length / 3)); + let best: { candidate: string; distance: number } | undefined; + + for (const candidate of candidates) { + const distance = levenshtein(normalized, candidate.toLowerCase()); + if (distance > maxDistance || distance >= candidate.length) continue; + if (!best || distance < best.distance) { + best = { candidate, distance }; + } + } + + return best?.candidate; +} + +export function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + let previous = Array.from({ length: b.length + 1 }, (_, index) => index); + + for (let i = 1; i <= a.length; i += 1) { + const current = [i]; + for (let j = 1; j <= b.length; j += 1) { + const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1; + current[j] = Math.min( + previous[j]! + 1, + current[j - 1]! + 1, + previous[j - 1]! + substitutionCost, + ); + } + previous = current; + } + + return previous[b.length]!; +} diff --git a/tests/cli/examplePlugin.test.ts b/tests/cli/examplePlugin.test.ts new file mode 100644 index 0000000..5fa71e7 --- /dev/null +++ b/tests/cli/examplePlugin.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cliEntrypoint = join(repoRoot, "src", "cli", "index.ts"); +const exampleDir = join(repoRoot, "examples", "plugin"); + +function runScan(args: string[], env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, ["--import", "tsx", cliEntrypoint, "scan", ...args], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, ...env }, + }); +} + +describe("examples/plugin reference plugin", () => { + it("loads the no-console plugin and reports its finding", () => { + const result = runScan([".", "--cwd", exampleDir, "--rules", "no-console", "--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, "no-console"); + assert.equal(parsed.issues[0].file, "src/app.ts"); + assert.equal(parsed.issues[0].location.startLine, 2); + }); + + it("respects DEBTLENS_DISABLE_PLUGINS for the example config", () => { + const result = runScan([".", "--cwd", exampleDir, "--format", "json"], { + DEBTLENS_DISABLE_PLUGINS: "1", + }); + const parsed = JSON.parse(result.stdout); + + assert.equal(result.status, 0); + assert.ok(!parsed.issues.some((issue: { ruleId: string }) => issue.ruleId === "no-console")); + }); +}); diff --git a/tests/cli/explain.test.ts b/tests/cli/explain.test.ts new file mode 100644 index 0000000..fc35ee6 --- /dev/null +++ b/tests/cli/explain.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cliEntrypoint = join(repoRoot, "src", "cli", "index.ts"); + +function runCli(args: string[]) { + return spawnSync(process.execPath, ["--import", "tsx", cliEntrypoint, ...args], { + cwd: repoRoot, + encoding: "utf8", + }); +} + +describe("debtlens explain", () => { + it("prints rule metadata and default thresholds", () => { + const result = runCli(["explain", "prop-drilling"]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Prop drilling \[prop-drilling\]/); + assert.match(result.stdout, /Default severity: /); + assert.match(result.stdout, /prop-drilling\.maxForwardedProps: 4/); + }); + + it("includes false-positive guidance from docs/rules.md", () => { + const result = runCli(["explain", "large-component"]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /When this is a false positive/); + assert.match(result.stdout, /large-component\.maxLines: 250/); + }); + + it("exits with a did-you-mean error for unknown rule ids", () => { + const result = runCli(["explain", "todo-comments"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown DebtLens rule "todo-comments"/); + assert.match(result.stderr, /Did you mean "todo-comment"\?/); + assert.match(result.stderr, /debtlens rules/); + }); + + it("exits without a suggestion when nothing is close", () => { + const result = runCli(["explain", "zzzz"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown DebtLens rule "zzzz"/); + assert.doesNotMatch(result.stderr, /Did you mean/); + }); +}); + +describe("debtlens scan unknown rules", () => { + it("suggests the closest rule id for --rules typos", () => { + const result = runCli(["scan", "examples/react", "--rules", "todo-comments"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown DebtLens rule\(s\): todo-comments \(did you mean "todo-comment"\?\)/); + }); +}); diff --git a/tests/cli/plugins.test.ts b/tests/cli/plugins.test.ts new file mode 100644 index 0000000..6d5d54b --- /dev/null +++ b/tests/cli/plugins.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cliEntrypoint = join(repoRoot, "src", "cli", "index.ts"); + +function runScan(args: string[], options: { cwd?: string; env?: NodeJS.ProcessEnv } = {}) { + return spawnSync(process.execPath, ["--import", "tsx", cliEntrypoint, "scan", ...args], { + cwd: options.cwd ?? repoRoot, + encoding: "utf8", + env: { ...process.env, ...(options.env ?? {}) }, + }); +} + +const pluginSource = ` +export default { + id: "no-console", + name: "No console", + description: "Flags console.log in production source.", + defaultSeverity: "low", + tags: ["hygiene"], + detect(context) { + const issues = []; + for (const file of context.files) { + const lines = file.content.split(/\\r?\\n/); + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].includes("console.log")) { + 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 or route through a logger.", + }); + } + } + } + return issues; + }, +}; +`; + +function withPluginProject(run: (dir: string) => void) { + const dir = mkdtempSync(join(tmpdir(), "debtlens-cli-plugin-")); + try { + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "no-console.mjs"), pluginSource); + writeFileSync(join(dir, "src", "app.ts"), "console.log(\"debug\");\nexport const value = 1;\n"); + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify({ + pluginApiVersion: 1, + plugins: ["./no-console.mjs"], + })); + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("debtlens scan with plugins", () => { + it("runs plugin detectors alongside built-in rules", () => { + withPluginProject((dir) => { + const result = runScan([".", "--cwd", dir, "--format", "json"]); + const parsed = JSON.parse(result.stdout); + + assert.equal(result.status, 0); + assert.ok(parsed.issues.some((issue: { ruleId: string }) => issue.ruleId === "no-console")); + }); + }); + + it("selects plugin rules explicitly via --rules", () => { + withPluginProject((dir) => { + const result = runScan([".", "--cwd", dir, "--rules", "no-console", "--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, "no-console"); + assert.equal(parsed.issues[0].file, "src/app.ts"); + }); + }); + + it("supports inline suppressions for plugin rules", () => { + withPluginProject((dir) => { + writeFileSync( + join(dir, "src", "app.ts"), + "// debtlens-disable-next-line no-console -- intentional CLI output\nconsole.log(\"debug\");\n", + ); + + const result = runScan([".", "--cwd", dir, "--rules", "no-console", "--format", "json"]); + const parsed = JSON.parse(result.stdout); + + assert.equal(result.status, 0); + assert.equal(parsed.summary.totalIssues, 0); + assert.equal(parsed.summary.filterStats?.suppressedByInline, 1); + }); + }); + + it("skips plugins with a stderr note when DEBTLENS_DISABLE_PLUGINS=1", () => { + withPluginProject((dir) => { + const result = runScan([".", "--cwd", dir, "--format", "json"], { + env: { DEBTLENS_DISABLE_PLUGINS: "1" }, + }); + const parsed = JSON.parse(result.stdout); + + assert.equal(result.status, 0); + assert.match(result.stderr, /plugins configured but skipped because DEBTLENS_DISABLE_PLUGINS=1/); + assert.ok(!parsed.issues.some((issue: { ruleId: string }) => issue.ruleId === "no-console")); + }); + }); + + 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")); + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify({ + pluginApiVersion: 1, + plugins: ["./todo-clone.mjs"], + })); + + const result = runScan([".", "--cwd", dir, "--format", "json"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /collides with an existing rule/); + }); + }); +}); diff --git a/tests/cli/scan.test.ts b/tests/cli/scan.test.ts index 36a332b..032e137 100644 --- a/tests/cli/scan.test.ts +++ b/tests/cli/scan.test.ts @@ -187,6 +187,72 @@ describe("debtlens scan fail-on confidence", () => { }); }); +describe("debtlens scan failOn from config", () => { + function withTempProject(run: (dir: string) => void) { + const dir = mkdtempSync(join(tmpdir(), "debtlens-cli-failon-")); + try { + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "Widget.ts"), "// TODO remove after launch\nexport const value = 1;\n"); + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + } + + it("gates the exit code from config-only failOn", () => { + withTempProject((dir) => { + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify({ + rules: ["todo-comment"], + failOn: "low", + })); + + const result = runScan([".", "--cwd", dir, "--format", "json"]); + + assert.equal(result.status, 1); + }); + }); + + it("does not fail when config failOn severity is not met", () => { + withTempProject((dir) => { + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify({ + rules: ["todo-comment"], + failOn: "high", + })); + + const result = runScan([".", "--cwd", dir, "--format", "json"]); + + assert.equal(result.status, 0); + }); + }); + + it("lets the --fail-on flag override config failOn", () => { + withTempProject((dir) => { + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify({ + rules: ["todo-comment"], + failOn: "low", + })); + + const result = runScan([".", "--cwd", dir, "--fail-on", "high", "--format", "json"]); + + assert.equal(result.status, 0); + }); + }); + + it("rejects an invalid failOn severity in config", () => { + withTempProject((dir) => { + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify({ + rules: ["todo-comment"], + failOn: "critical", + })); + + const result = runScan([".", "--cwd", dir, "--format", "json"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Invalid severity "critical"/); + }); + }); +}); + describe("debtlens scan inline suppressions", () => { function withTempProject(run: (dir: string) => void) { const dir = mkdtempSync(join(tmpdir(), "debtlens-cli-suppress-")); diff --git a/tests/config/pluginConfig.test.ts b/tests/config/pluginConfig.test.ts new file mode 100644 index 0000000..a9d5010 --- /dev/null +++ b/tests/config/pluginConfig.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { loadConfig } from "../../src/config/loadConfig.js"; +import { DEBTLENS_PLUGIN_API_VERSION } from "../../src/plugins/version.js"; + +function withTempConfig(config: Record, run: (dir: string) => void) { + const dir = mkdtempSync(join(tmpdir(), "debtlens-plugin-config-")); + try { + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify(config)); + run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("plugin config validation", () => { + it("loads a config with a matching pluginApiVersion", () => { + withTempConfig({ pluginApiVersion: DEBTLENS_PLUGIN_API_VERSION, plugins: ["./plugin.mjs"] }, (dir) => { + const config = loadConfig(dir); + assert.deepEqual(config.plugins, ["./plugin.mjs"]); + assert.equal(config.pluginApiVersion, DEBTLENS_PLUGIN_API_VERSION); + }); + }); + + it("rejects plugins without a pluginApiVersion", () => { + withTempConfig({ plugins: ["./plugin.mjs"] }, (dir) => { + assert.throws(() => loadConfig(dir), new RegExp(`"plugins" requires "pluginApiVersion": ${DEBTLENS_PLUGIN_API_VERSION}`)); + }); + }); + + it("rejects an unsupported pluginApiVersion with an upgrade message", () => { + withTempConfig({ pluginApiVersion: 999, plugins: ["./plugin.mjs"] }, (dir) => { + assert.throws( + () => loadConfig(dir), + /pluginApiVersion 999 is not supported by this DebtLens release \(supported: 1\)/, + ); + }); + }); + + it("ignores plugin fields when neither is present", () => { + withTempConfig({ minSeverity: "low" }, (dir) => { + const config = loadConfig(dir); + assert.equal(config.plugins, undefined); + }); + }); +}); diff --git a/tests/config/schema.test.ts b/tests/config/schema.test.ts index 6938a2e..caffac4 100644 --- a/tests/config/schema.test.ts +++ b/tests/config/schema.test.ts @@ -9,13 +9,14 @@ import { detectorIds } from "../../src/detectors/index.js"; type SchemaShape = { $id: string; properties: { - rules: { items: { enum: string[] } }; + rules: { items: { anyOf: [{ enum: string[] }, { type: string }] } }; minSeverity: { enum: string[] }; respectGitignore: { type: string }; }; }; const schema = buildConfigSchema() as unknown as SchemaShape; +const ruleEnum = schema.properties.rules.items.anyOf[0].enum; describe("config JSON schema", () => { it("matches the committed schema file (no drift)", () => { @@ -24,13 +25,25 @@ describe("config JSON schema", () => { }); it("lists every detector id in the rules enum", () => { - const ruleEnum = schema.properties.rules.items.enum; for (const id of detectorIds) { assert.ok(ruleEnum.includes(id), `missing rule id in schema: ${id}`); } assert.equal(ruleEnum.length, detectorIds.length); }); + it("accepts plugin rule ids as plain strings in rules", () => { + assert.equal(schema.properties.rules.items.anyOf[1].type, "string"); + }); + + it("includes plugin configuration fields", () => { + const built = buildConfigSchema() as { + properties: { pluginApiVersion?: { type: string; minimum: number }; plugins?: { type: string } }; + }; + assert.equal(built.properties.pluginApiVersion?.type, "integer"); + assert.equal(built.properties.pluginApiVersion?.minimum, 1); + assert.equal(built.properties.plugins?.type, "array"); + }); + it("uses the canonical severity set", () => { assert.deepEqual(schema.properties.minSeverity.enum, [...severities]); }); @@ -49,7 +62,6 @@ describe("config JSON schema", () => { it("validates the example config's rules and severity", () => { const example = JSON.parse(readFileSync("debtlens.config.example.json", "utf8")); - const ruleEnum = schema.properties.rules.items.enum; assert.equal(example.$schema, SCHEMA_ID); for (const rule of example.rules) { assert.ok(ruleEnum.includes(rule), `example uses unknown rule: ${rule}`); @@ -59,6 +71,11 @@ describe("config JSON schema", () => { assert.equal(typeof example.respectGitignore, "boolean"); }); + it("includes failOn with the canonical severity set", () => { + const built = buildConfigSchema() as { properties: { failOn?: { enum: string[] } } }; + assert.deepEqual(built.properties.failOn?.enum, [...severities]); + }); + it("includes todoComment config shape", () => { const built = buildConfigSchema() as { properties: { todoComment?: { type: string }; pack?: { enum: string[] } } }; assert.equal(built.properties.todoComment?.type, "object"); diff --git a/tests/core/suppressions.test.ts b/tests/core/suppressions.test.ts index 7bae1b7..408f8b6 100644 --- a/tests/core/suppressions.test.ts +++ b/tests/core/suppressions.test.ts @@ -51,6 +51,13 @@ describe("inline suppressions", () => { assert.match(result.warnings[0] ?? "", /unknown suppression rule/); }); + it("suggests the closest rule id for an unknown suppression rule", () => { + const files = [file("src/a.ts", "// debtlens-disable-next-line todo-comments -- reason\n")]; + const result = applyInlineSuppressions([issue()], files, validRuleIds); + assert.equal(result.issues.length, 1); + assert.match(result.warnings[0] ?? "", /did you mean "todo-comment"\?/); + }); + it("suppresses file-level findings for the configured rule", () => { const files = [file("src/a.ts", "// debtlens-disable-file naming-drift -- domain vocabulary is intentional\nconst movie = 1;\n")]; const result = applyInlineSuppressions([ diff --git a/tests/plugins/loadPlugins.test.ts b/tests/plugins/loadPlugins.test.ts new file mode 100644 index 0000000..e824671 --- /dev/null +++ b/tests/plugins/loadPlugins.test.ts @@ -0,0 +1,152 @@ +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 { loadPlugins, pluginsDisabled } from "../../src/plugins/loadPlugins.js"; + +const builtInIds = new Set(["todo-comment"]); + +const validDetectorSource = (id: string) => ` +export default { + id: "${id}", + name: "Example", + description: "Example plugin rule.", + defaultSeverity: "low", + tags: ["example"], + detect: () => [], +}; +`; + +async function withTempDir(run: (dir: string) => Promise) { + const dir = mkdtempSync(join(tmpdir(), "debtlens-plugins-")); + try { + await run(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("loadPlugins", () => { + it("returns no detectors when no plugins are configured", async () => { + const result = await loadPlugins("/anywhere", {}, builtInIds); + assert.deepEqual(result.detectors, []); + assert.deepEqual(result.warnings, []); + }); + + it("loads a single default-exported detector", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "plugin.mjs"), validDetectorSource("no-console")); + const result = await loadPlugins(dir, { plugins: ["./plugin.mjs"] }, builtInIds); + assert.equal(result.detectors.length, 1); + assert.equal(result.detectors[0]?.id, "no-console"); + }); + }); + + it("loads a { rules } export and warns on unsupported vocabulary", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "plugin.mjs"), ` +const rule = { + id: "custom-rule", + name: "Custom", + description: "Custom rule.", + defaultSeverity: "medium", + tags: [], + detect: () => [], +}; +export default { rules: [rule], 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/); + }); + }); + + it("rejects rule id collisions with built-in rules", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "plugin.mjs"), validDetectorSource("todo-comment")); + await assert.rejects( + loadPlugins(dir, { plugins: ["./plugin.mjs"] }, builtInIds), + /rule id "todo-comment", which collides with an existing rule/, + ); + }); + }); + + it("rejects rule id collisions between plugins", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "one.mjs"), validDetectorSource("dup-rule")); + writeFileSync(join(dir, "two.mjs"), validDetectorSource("dup-rule")); + await assert.rejects( + loadPlugins(dir, { plugins: ["./one.mjs", "./two.mjs"] }, builtInIds), + /collides with an existing rule/, + ); + }); + }); + + it("rejects invalid detector shapes with a clear field error", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "plugin.mjs"), ` +export default { + id: "broken-rule", + name: "Broken", + description: "Missing detect.", + defaultSeverity: "low", + tags: [], +}; +`); + await assert.rejects( + loadPlugins(dir, { plugins: ["./plugin.mjs"] }, builtInIds), + /"detect" must be a function/, + ); + }); + }); + + it("rejects exports that are neither a detector nor { rules }", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "plugin.mjs"), "export default 42;\n"); + await assert.rejects( + loadPlugins(dir, { plugins: ["./plugin.mjs"] }, builtInIds), + /must default-export a Detector or \{ rules: Detector\[\] \}/, + ); + }); + }); + + it("rejects paths that traverse outside the config directory", async () => { + await withTempDir(async (dir) => { + const nested = join(dir, "nested"); + mkdirSync(nested); + writeFileSync(join(dir, "outside.mjs"), validDetectorSource("outside-rule")); + await assert.rejects( + loadPlugins(nested, { plugins: ["../outside.mjs"] }, builtInIds), + /resolves outside the config directory/, + ); + }); + }); + + it("rejects missing plugin modules", async () => { + await withTempDir(async (dir) => { + await assert.rejects( + loadPlugins(dir, { plugins: ["./missing.mjs"] }, builtInIds), + /Plugin module not found/, + ); + }); + }); + + it("skips loading entirely when DEBTLENS_DISABLE_PLUGINS=1", async () => { + await withTempDir(async (dir) => { + writeFileSync(join(dir, "plugin.mjs"), validDetectorSource("no-console")); + const result = await loadPlugins(dir, { plugins: ["./plugin.mjs"] }, builtInIds, { + DEBTLENS_DISABLE_PLUGINS: "1", + }); + assert.deepEqual(result.detectors, []); + assert.match(result.warnings[0] ?? "", /skipped because DEBTLENS_DISABLE_PLUGINS=1/); + }); + }); + + it("reports plugin disable state from the environment", () => { + assert.equal(pluginsDisabled({ DEBTLENS_DISABLE_PLUGINS: "1" }), true); + assert.equal(pluginsDisabled({ DEBTLENS_DISABLE_PLUGINS: "0" }), false); + assert.equal(pluginsDisabled({}), false); + }); +}); diff --git a/tests/plugins/pluginEntry.test.ts b/tests/plugins/pluginEntry.test.ts new file mode 100644 index 0000000..c65f3fb --- /dev/null +++ b/tests/plugins/pluginEntry.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { describe, it } from "node:test"; +import { DEBTLENS_PLUGIN_API_VERSION } from "../../src/plugin.js"; +import type { DebtIssue, Detector, DetectorContext, Severity } from "../../src/plugin.js"; + +describe("debtlens/plugin entry point", () => { + it("exports the plugin API version", () => { + assert.equal(DEBTLENS_PLUGIN_API_VERSION, 1); + }); + + it("exposes the detector contract types", () => { + // Compile-time assertion: a detector written against the public entry + // type-checks (verified by npm run typecheck:tests). + const severity: Severity = "low"; + const detector: Detector = { + id: "entry-check", + name: "Entry check", + description: "Types-only detector exercising the published surface.", + defaultSeverity: severity, + tags: [], + detect: (context: DetectorContext): DebtIssue[] => { + void context.getThreshold("entry-check.max", 1); + return []; + }, + }; + + assert.equal(detector.id, "entry-check"); + }); + + it("is published via the package exports map", () => { + const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as { + exports?: Record; + }; + + assert.equal(packageJson.exports?.["./plugin"]?.types, "./dist/plugin.d.ts"); + assert.equal(packageJson.exports?.["./plugin"]?.import, "./dist/plugin.js"); + }); +}); diff --git a/tests/utils/didYouMean.test.ts b/tests/utils/didYouMean.test.ts new file mode 100644 index 0000000..936f297 --- /dev/null +++ b/tests/utils/didYouMean.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { levenshtein, suggestClosest } from "../../src/utils/didYouMean.js"; +import { detectorIds } from "../../src/detectors/index.js"; + +describe("levenshtein", () => { + it("measures edit distance", () => { + assert.equal(levenshtein("todo-comment", "todo-comment"), 0); + assert.equal(levenshtein("todo-comments", "todo-comment"), 1); + assert.equal(levenshtein("", "abc"), 3); + assert.equal(levenshtein("kitten", "sitting"), 3); + }); +}); + +describe("suggestClosest", () => { + it("suggests the canonical rule id for a plural typo", () => { + assert.equal(suggestClosest("todo-comments", detectorIds), "todo-comment"); + }); + + it("suggests across transposed and dropped characters", () => { + assert.equal(suggestClosest("prop-driling", detectorIds), "prop-drilling"); + assert.equal(suggestClosest("naming-dirft", detectorIds), "naming-drift"); + }); + + it("returns undefined when nothing is plausibly close", () => { + assert.equal(suggestClosest("zzzz", detectorIds), undefined); + }); +});