diff --git a/README.md b/README.md index 46b5275..2c77c9a 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ npx debtlens scan ```bash debtlens init # write a starter debtlens.config.json (use --force to overwrite) debtlens init --pack core # starter config using the core rule pack preset +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 @@ -170,6 +171,17 @@ debtlens rules --format json debtlens scan --quiet ``` +## Recommended adoption path + +Preview findings and get a `minSeverity` recommendation before committing config or baseline files: + +```bash +debtlens adopt --cwd . --rules todo-comment # dry-run report (default) +debtlens adopt --write-config --write-baseline --force +``` + +The second command writes `debtlens.config.json` and `debtlens-baseline.json` (baseline write is skipped when zero issues are found). After adoption, use `debtlens scan --baseline debtlens-baseline.json --fail-on high` in CI to gate only newly introduced debt. + Baseline fingerprints are stable across line shifts, so moving existing code up or down does not resurface already-recorded debt — only genuinely new issues are reported. When a scan reads zero files, DebtLens prints a stderr warning with likely causes such as include/exclude globs, the target path, `--cwd`, or an empty git file set from `--changed` / `--staged`. The warning is advisory and does not change the exit code for `--fail-on`. diff --git a/src/cli/adopt.ts b/src/cli/adopt.ts new file mode 100644 index 0000000..98b0346 --- /dev/null +++ b/src/cli/adopt.ts @@ -0,0 +1,126 @@ +import { loadConfig } from "../config/loadConfig.js"; +import { mergeConfig } from "../config/mergeConfig.js"; +import { DEFAULT_BASELINE_FILENAME, createBaseline, writeBaseline } from "../core/baseline.js"; +import { scan } from "../core/scan.js"; +import { severities } from "../core/severity.js"; +import type { CliOptions, ScanResult, Severity } from "../core/types.js"; +import { runInit } from "./init.js"; +import { buildZeroFilesScannedWarning } from "./scanWarnings.js"; + +export interface AdoptInput { + target: string; + cwd: string; + configPath?: string; + cliOptions: CliOptions; + writeConfig?: boolean; + force?: boolean; + pack?: string; + writeBaseline?: boolean | string; +} + +export interface AdoptResult { + text: string; + scan: ScanResult; + configWritten?: string; + baselineWritten?: string; + baselineSkipped?: boolean; +} + +export function recommendMinSeverity(bySeverity: Record, total: number): Severity { + if (total === 0) return "low"; + + const lowNoise = bySeverity.info + bySeverity.low; + if (total >= 10 && lowNoise / total >= 0.7) return "medium"; + if (total >= 20 && lowNoise / total >= 0.5) return "medium"; + + return "low"; +} + +export function formatAdoptReport(scanResult: ScanResult, recommendedMinSeverity: Severity): string { + const { summary } = scanResult; + const topRules = Object.entries(summary.byRule) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5); + + const lines = [ + "DebtLens Adoption Report", + "========================", + `Files scanned: ${summary.filesScanned}`, + `Total issues: ${summary.totalIssues}`, + "", + "By severity:", + ...severities.map((severity) => ` ${severity}: ${summary.bySeverity[severity]}`), + "", + "Top rules:", + ...(topRules.length > 0 + ? topRules.map(([rule, count]) => ` ${rule}: ${count}`) + : [" (none)"]), + "", + `Recommended minSeverity: ${recommendedMinSeverity}`, + ]; + + return `${lines.join("\n")}\n`; +} + +export async function runAdopt(input: AdoptInput): Promise { + const fileConfig = loadConfig(input.cwd, input.configPath); + const options = mergeConfig(input.target, fileConfig, input.cliOptions); + const result = await scan(options); + + const recommended = recommendMinSeverity(result.summary.bySeverity, result.summary.totalIssues); + const lines: string[] = []; + + if (result.summary.filesScanned === 0) { + lines.push(buildZeroFilesScannedWarning(options.target, options.include, false).trimEnd()); + lines.push(""); + } + + if (result.summary.warnings?.length) { + for (const warning of result.summary.warnings) { + lines.push(`DebtLens warning: ${warning}`); + } + lines.push(""); + } + + lines.push(formatAdoptReport(result, recommended).trimEnd()); + + let configWritten: string | undefined; + let baselineWritten: string | undefined; + let baselineSkipped = false; + + if (input.writeConfig) { + const initResult = runInit(input.cwd, input.force === true, input.pack); + configWritten = initResult.path; + lines.push(""); + lines.push(`${initResult.overwritten ? "Overwrote" : "Created"} ${initResult.path}`); + } + + if (input.writeBaseline !== undefined && input.writeBaseline !== false) { + if (result.issues.length === 0) { + baselineSkipped = true; + lines.push(""); + lines.push("Skipped baseline write (0 issues found)."); + } else { + const baselinePath = input.writeBaseline === true + ? DEFAULT_BASELINE_FILENAME + : String(input.writeBaseline); + baselineWritten = writeBaseline(input.cwd, baselinePath, createBaseline(result.issues)); + lines.push(""); + lines.push(`Wrote baseline with ${result.issues.length} issues to ${baselineWritten}`); + } + } + + const dryRun = !input.writeConfig && input.writeBaseline === undefined; + if (dryRun) { + lines.push(""); + lines.push("Dry run — no files written. Use --write-config --force and/or --write-baseline to apply."); + } + + return { + text: `${lines.join("\n")}\n`, + scan: result, + configWritten, + baselineWritten, + baselineSkipped, + }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index e5848d5..f3691ec 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -15,6 +15,7 @@ import { packageVersion } from "../utils/packageInfo.js"; import { renderReport } from "../reporters/index.js"; import { runInit } from "./init.js"; import { runDoctor } from "./doctor.js"; +import { runAdopt } from "./adopt.js"; import { parseCommaList, parseThresholds } from "./parseList.js"; import { buildZeroFilesScannedWarning } from "./scanWarnings.js"; @@ -309,6 +310,48 @@ program.command("init") } }); +program.command("adopt") + .description("Scan and print an adoption summary; optionally write config and baseline.") + .argument("[target]", "directory or file to scan", ".") + .option("-i, --include ", "comma-separated glob patterns to include") + .option("-x, --exclude ", "comma-separated glob patterns to exclude") + .option("--min-severity ", "info, low, medium, or high", "low") + .option("--pack ", `built-in rule pack preset (${RULE_PACK_IDS.join(", ")})`) + .option("--rules ", `comma-separated rule ids. Available: ${detectorIds.join(", ")}`) + .option("--config ", "path to debtlens.config.json") + .option("--cwd ", "working directory", process.cwd()) + .option("--write-config", "write debtlens.config.json") + .option("--force", "overwrite an existing config file (required with --write-config)") + .option("--write-baseline [path]", "write baseline file (skipped when 0 issues)") + .action(async (target: string, rawOptions: Record) => { + try { + const cwd = resolve(String(rawOptions.cwd ?? process.cwd())); + const report = await runAdopt({ + target, + cwd, + configPath: rawOptions.config ? String(rawOptions.config) : undefined, + pack: rawOptions.pack ? String(rawOptions.pack) : undefined, + writeConfig: rawOptions.writeConfig === true, + force: rawOptions.force === true, + writeBaseline: rawOptions.writeBaseline as boolean | string | undefined, + cliOptions: { + cwd, + include: parseCommaList(rawOptions.include as string | undefined), + exclude: parseCommaList(rawOptions.exclude as string | undefined), + rules: parseRuleList(rawOptions.rules as string | undefined), + pack: rawOptions.pack ? String(rawOptions.pack) : undefined, + minSeverity: parseSeverity(String(rawOptions.minSeverity ?? "low"), "low"), + }, + }); + + process.stdout.write(report.text); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`DebtLens failed: ${message}\n`); + process.exitCode = 1; + } + }); + if (process.argv.length <= 2) { program.help(); } diff --git a/tests/cli/adopt.test.ts b/tests/cli/adopt.test.ts new file mode 100644 index 0000000..6ced5a3 --- /dev/null +++ b/tests/cli/adopt.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { CONFIG_FILENAME } from "../../src/cli/init.js"; +import { DEFAULT_BASELINE_FILENAME } from "../../src/core/baseline.js"; +import { recommendMinSeverity } from "../../src/cli/adopt.js"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cliEntrypoint = join(repoRoot, "src", "cli", "index.ts"); + +function runAdopt(args: string[], options: { cwd?: string } = {}) { + return spawnSync(process.execPath, ["--import", "tsx", cliEntrypoint, "adopt", ...args], { + cwd: options.cwd ?? repoRoot, + encoding: "utf8", + env: process.env, + }); +} + +describe("recommendMinSeverity", () => { + it("suggests medium when low-severity noise dominates", () => { + const recommendation = recommendMinSeverity( + { info: 2, low: 12, medium: 3, high: 1 }, + 18, + ); + assert.equal(recommendation, "medium"); + }); + + it("keeps low when issue volume is small", () => { + const recommendation = recommendMinSeverity( + { info: 1, low: 2, medium: 0, high: 0 }, + 3, + ); + assert.equal(recommendation, "low"); + }); +}); + +describe("debtlens adopt", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "debtlens-adopt-")); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(join(dir, "src", "app.ts"), "// TODO: adoption test marker\nexport const ok = 1;\n"); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("prints an adoption report in dry-run mode by default", () => { + const result = runAdopt([".", "--cwd", dir, "--rules", "todo-comment"]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /DebtLens Adoption Report/); + assert.match(result.stdout, /Total issues: 1/); + assert.match(result.stdout, /todo-comment: 1/); + assert.match(result.stdout, /Dry run — no files written/); + assert.equal(existsSync(join(dir, CONFIG_FILENAME)), false); + assert.equal(existsSync(join(dir, DEFAULT_BASELINE_FILENAME)), false); + }); + + it("writes config and baseline when requested", () => { + const result = runAdopt([ + ".", + "--cwd", + dir, + "--rules", + "todo-comment", + "--write-config", + "--write-baseline", + "--force", + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Created .*debtlens\.config\.json/); + assert.match(result.stdout, /Wrote baseline with 1 issues/); + assert.equal(existsSync(join(dir, CONFIG_FILENAME)), true); + assert.equal(existsSync(join(dir, DEFAULT_BASELINE_FILENAME)), true); + + const baseline = JSON.parse(readFileSync(join(dir, DEFAULT_BASELINE_FILENAME), "utf8")); + assert.ok(Object.keys(baseline.fingerprints).length >= 1); + }); + + it("skips baseline write when no issues are found", () => { + writeFileSync(join(dir, "src", "app.ts"), "export const ok = 1;\n"); + + const result = runAdopt([ + ".", + "--cwd", + dir, + "--rules", + "todo-comment", + "--write-baseline", + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Skipped baseline write \(0 issues found\)/); + assert.equal(existsSync(join(dir, DEFAULT_BASELINE_FILENAME)), false); + }); + + it("refuses to overwrite config without --force", () => { + writeFileSync(join(dir, CONFIG_FILENAME), "{}\n", "utf8"); + + const result = runAdopt([ + ".", + "--cwd", + dir, + "--rules", + "todo-comment", + "--write-config", + ]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /already exists/); + }); +});