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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down
126 changes: 126 additions & 0 deletions src/cli/adopt.ts
Original file line number Diff line number Diff line change
@@ -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<Severity, number>, 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<AdoptResult> {
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,
};
}
43 changes: 43 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 <patterns>", "comma-separated glob patterns to include")
.option("-x, --exclude <patterns>", "comma-separated glob patterns to exclude")
.option("--min-severity <severity>", "info, low, medium, or high", "low")
.option("--pack <pack>", `built-in rule pack preset (${RULE_PACK_IDS.join(", ")})`)
.option("--rules <rules>", `comma-separated rule ids. Available: ${detectorIds.join(", ")}`)
.option("--config <path>", "path to debtlens.config.json")
.option("--cwd <path>", "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<string, unknown>) => {
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();
}
Expand Down
121 changes: 121 additions & 0 deletions tests/cli/adopt.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});