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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ All notable changes to DebtLens are documented here. This project adheres to
- Payoff ranking via `--sort payoff`, JSON `payoffScore`, and top-payoff report sections.
- `debtlens calibrate` for percentile-based threshold suggestions with optional `--write`.
- Interactive `debtlens triage` for keep/baseline/suppress workflows.
- `debtlens fix` dry-run autofix allowlist for duplicated literals.
- Opt-in `feature-flags` pack with `stale-feature-flag` detector.
- `--concurrency` and `--cache-dir` scan controls for large-repo performance.
- `--ownership-report` scorecards with count and payoff leaderboards.
- HTML reports now include import-graph SVG and directory debt treemap views.
- Core rules: `long-parameter-list`, `god-file`, and `cognitive-complexity` for function/module
Expand Down
2 changes: 1 addition & 1 deletion docs/example-report.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DebtLens Report

Scanned **3** files with **35** rules in **162ms**.
Scanned **3** files with **36** rules in **162ms**.

## Summary

Expand Down
58 changes: 55 additions & 3 deletions schema/debtlens.config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@
"compose",
"ai-assisted-maintainer",
"oss-maintainer",
"ai-workflow-drift"
"ai-workflow-drift",
"feature-flags"
]
},
{
"type": "string",
"pattern": "^\\s*(?:core|react|react-native|next|expo|node|python|python-web|vue|svelte|kotlin|swift|swiftui|ruby|rails|compose|ai-assisted-maintainer|oss-maintainer|ai-workflow-drift)(?:\\s*,\\s*(?:core|react|react-native|next|expo|node|python|python-web|vue|svelte|kotlin|swift|swiftui|ruby|rails|compose|ai-assisted-maintainer|oss-maintainer|ai-workflow-drift))*\\s*$"
"pattern": "^\\s*(?:core|react|react-native|next|expo|node|python|python-web|vue|svelte|kotlin|swift|swiftui|ruby|rails|compose|ai-assisted-maintainer|oss-maintainer|ai-workflow-drift|feature-flags)(?:\\s*,\\s*(?:core|react|react-native|next|expo|node|python|python-web|vue|svelte|kotlin|swift|swiftui|ruby|rails|compose|ai-assisted-maintainer|oss-maintainer|ai-workflow-drift|feature-flags))*\\s*$"
}
],
"description": "Built-in rule pack preset, or a comma-separated list of presets. Explicit rules override the pack."
Expand Down Expand Up @@ -137,7 +138,8 @@
"compose-large-composable",
"compose-state-hoisting",
"ai-instruction-duplication",
"ai-instruction-contradiction"
"ai-instruction-contradiction",
"stale-feature-flag"
]
},
{
Expand Down Expand Up @@ -937,6 +939,14 @@
"medium",
"high"
]
},
"stale-feature-flag": {
"enum": [
"info",
"low",
"medium",
"high"
]
}
},
"additionalProperties": {
Expand Down Expand Up @@ -1286,6 +1296,11 @@
"type": "number",
"minimum": 0,
"maximum": 1
},
"stale-feature-flag": {
"type": "number",
"minimum": 0,
"maximum": 1
}
},
"additionalProperties": {
Expand Down Expand Up @@ -1441,6 +1456,43 @@
"minimum": 0
}
}
},
"priority": {
"type": "object",
"description": "Payoff ranking weights for --sort payoff.",
"additionalProperties": false,
"properties": {
"churn": {
"type": "number",
"minimum": 0
},
"age": {
"type": "number",
"minimum": 0
},
"severity": {
"type": "object",
"additionalProperties": false,
"properties": {
"info": {
"type": "number",
"minimum": 0
},
"low": {
"type": "number",
"minimum": 0
},
"medium": {
"type": "number",
"minimum": 0
},
"high": {
"type": "number",
"minimum": 0
}
}
}
}
}
}
}
61 changes: 61 additions & 0 deletions schema/debtlens.scan-result.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
"type": "integer",
"minimum": 0
},
"payoffScore": {
"type": "number",
"minimum": 0
},
"location": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -202,6 +206,10 @@
"type": "integer",
"minimum": 0
},
"payoffScore": {
"type": "number",
"minimum": 0
},
"location": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -651,6 +659,55 @@
}
}
},
"importGraph": {
"type": "object",
"additionalProperties": false,
"required": [
"nodes",
"edges",
"cycles"
],
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "string"
}
},
"edges": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"from",
"to",
"inCycle"
],
"properties": {
"from": {
"type": "string"
},
"to": {
"type": "string"
},
"inCycle": {
"type": "boolean"
}
}
}
},
"cycles": {
"type": "array",
"items": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"hotspots": {
"type": "object",
"additionalProperties": false,
Expand Down Expand Up @@ -1238,6 +1295,10 @@
},
"parallel": {
"type": "boolean"
},
"concurrency": {
"type": "integer",
"minimum": 1
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/cli/adoptionThresholds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface ThresholdSuggestion {
suggested: number;
observedP90: number;
samples: number;
observedValues?: number[];
}

interface EvidenceThreshold {
Expand Down Expand Up @@ -51,7 +52,7 @@ export function buildThresholdSuggestions(result: ScanResult, options: ScanOptio
const current = options.thresholds[key];
const observedP90 = percentile(values, 0.9);
const suggested = Math.max(Math.ceil(observedP90 * 1.1), Math.ceil(current ?? 0));
return { key, current: current ?? 0, suggested, observedP90, samples: values.length };
return { key, current: current ?? 0, suggested, observedP90, samples: values.length, observedValues: [...values] };
})
.filter((suggestion) => suggestion.current > 0 && suggestion.suggested > suggestion.current)
.sort((left, right) => left.key.localeCompare(right.key));
Expand Down
48 changes: 48 additions & 0 deletions src/cli/commands/fix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Command } from "commander";
import { resolve } from "node:path";
import { loadEffectiveConfig } from "../../config/loadConfig.js";
import { mergeConfig } from "../../config/mergeConfig.js";
import { runFix } from "../fix.js";
import { parseCommaList, parseRuleList, parseThresholds } from "../parse.js";
import { parseSeverity } from "../../core/severity.js";
import { loadConfiguredPlugins } from "../scanPipeline.js";

export function registerFixCommand(program: Command): void {
program.command("fix")
.description("Apply a conservative allowlist of mechanical autofixes (dry-run by default).")
.argument("[target]", "directory or file to scan", ".")
.option("--rules <rules>", "comma-separated fixable rule ids (duplicated-literal, dead-abstraction)")
.option("--fix", "write fixes to disk (default is dry-run)")
.option("--cwd <path>", "working directory", process.cwd())
.option("--config <path>", "path to debtlens.config.json")
.action(async (target: string, rawOptions: Record<string, unknown>) => {
try {
const cwd = resolve(String(rawOptions.cwd ?? process.cwd()));
const effectiveConfig = loadEffectiveConfig(cwd, rawOptions.config ? String(rawOptions.config) : undefined);
const pluginContribution = await loadConfiguredPlugins(cwd, rawOptions, effectiveConfig.config, effectiveConfig.pluginConfigDir);
const options = mergeConfig(target, effectiveConfig.config, {
cwd,
include: parseCommaList(rawOptions.include as string | undefined),
exclude: parseCommaList(rawOptions.exclude as string | undefined),
rules: parseRuleList(rawOptions.rules as string | undefined),
thresholds: parseThresholds(rawOptions.threshold as string | undefined),
minSeverity: parseSeverity(String(rawOptions.minSeverity ?? "low"), "low"),
pluginDetectors: pluginContribution?.detectors,
});
const result = await runFix(options, {
rules: parseRuleList(rawOptions.rules as string | undefined),
dryRun: rawOptions.fix !== true,
});
if (result.diffs.length === 0) {
process.stdout.write(result.dryRun ? "No fixable findings.\n" : "No fixes applied.\n");
return;
}
process.stdout.write(`${result.dryRun ? "Dry-run" : "Applied"} ${result.filesTouched} file(s):\n`);
for (const diff of result.diffs) process.stdout.write(`${diff}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`DebtLens failed: ${message}\n`);
process.exitCode = 1;
}
});
}
21 changes: 14 additions & 7 deletions src/cli/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export function registerScanCommand(program: Command): void {
.option("--audit-suppressions", "include used and unused inline suppression directives in scan output")
.option("--cache [path]", "reuse unchanged scan results from a content-hash cache")
.option("--parallel", "run detectors concurrently after source loading")
.option("--concurrency <count>", "maximum concurrent detector runs for large scans (1 disables)", parseInteger)
.option("--cache-dir <path>", "shared cache directory for CI artifact restore")
.option("--batch-size <count>", "load source files in bounded batches", parseInteger)
.option("--blame-age", "add introducedDaysAgo metadata to JSON issues using git blame")
.option("--hotspots [limit]", "rank files by current findings plus recent git churn", parseOptionalInteger)
Expand Down Expand Up @@ -185,9 +187,11 @@ export async function runScanCommand(target: string, rawOptions: Record<string,
thresholds: parseThresholds(rawOptions.threshold as string | undefined),
minSeverity,
maxFiles: rawOptions.maxFiles as number | undefined,
cache: rawOptions.cache !== undefined ? true : undefined,
cache: rawOptions.cache !== undefined || rawOptions.cacheDir !== undefined ? true : undefined,
cachePath: typeof rawOptions.cache === "string" ? rawOptions.cache : undefined,
parallel: rawOptions.parallel === true ? true : undefined,
concurrency: rawOptions.concurrency as number | undefined,
cacheDir: typeof rawOptions.cacheDir === "string" ? rawOptions.cacheDir : undefined,
batchSize: rawOptions.batchSize as number | undefined,
respectGitignore: rawOptions.respectGitignore === true ? true : undefined,
changedFiles,
Expand Down Expand Up @@ -297,7 +301,7 @@ export async function runScanCommand(target: string, rawOptions: Record<string,

const badgeThresholds = parseBadgeThresholds(fileConfig.badge);

const report = renderReport(reported, format, {
let report = renderReport(reported, format, {
color: rawOptions.color !== false && format === "terminal" && process.stdout.isTTY === true,
quiet: rawOptions.quiet === true,
sourceUrlBase: format === "pr-comment" ? getGitHubSourceUrlBase(process.env) : undefined,
Expand All @@ -314,13 +318,16 @@ export async function runScanCommand(target: string, rawOptions: Record<string,

if (format === "badge" && rawOptions.output) {
const outputPath = resolve(cwd, String(rawOptions.output));
const jsonPath = outputPath.endsWith(".json")
? outputPath
: outputPath.endsWith(".svg")
const endpoint = renderBadgeEndpoint(reported, { thresholds: badgeThresholds });
if (outputPath.endsWith(".json")) {
report = endpoint;
} else {
const jsonPath = outputPath.endsWith(".svg")
? outputPath.replace(/\.svg$/, ".json")
: `${outputPath.replace(/\.(svg|json)$/i, "")}.json`;
mkdirSync(dirname(jsonPath), { recursive: true });
writeFileSync(jsonPath, renderBadgeEndpoint(reported, { thresholds: badgeThresholds }), "utf8");
mkdirSync(dirname(jsonPath), { recursive: true });
writeFileSync(jsonPath, endpoint, "utf8");
}
}

let exitCode = 0;
Expand Down
Loading