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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ All notable changes to DebtLens are documented here. This project adheres to

### Added

- `debtlens history record` / `history show` with `.debtlens/history.jsonl` ledger and timeline reports.
- 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.
- Core rules: `long-parameter-list`, `god-file`, and `cognitive-complexity` for function/module
design smells beyond single-axis size checks.
- Config `budgets` block and `debtlens scan --budget-report` for per-area debt SLO gating.
- `debtlens scan --format badge` emits a self-contained SVG badge plus shields.io endpoint JSON.
- Scan results now warn when matched files exceed `maxFiles`; terminal output prints the
advisory and JSON reports include it in `summary.warnings` while keeping
`summary.filesScanned` as the actual scanned count.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ language packs. Full taxonomy: [`docs/rule-packs.md`](./docs/rule-packs.md).
| `swallowed-error` | core | Catch blocks that only log without rethrowing or returning | Medium |
| `floating-promise` | core | Unawaited promise-returning calls and effect fire-and-forget | Medium |
| `commented-out-code` | core | Contiguous comment lines that look like dead code | Low |
| `long-parameter-list` | core | Functions with too many parameters or boolean-trap signatures | Medium |
| `god-file` | core | Kitchen-sink modules exceeding multiple sprawl thresholds | Medium |
| `cognitive-complexity` | core | Sonar-style cognitive complexity in nested control flow | Medium |
| `large-component` | react | React-style components with too many lines, hooks, or branch points | Medium |
| `state-sprawl` | react | Components/hooks with many local stateful hooks | Medium |
| `effect-complexity` | react | Long or overloaded React effect hooks | Medium |
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 **32** rules in **162ms**.
Scanned **3** files with **35** rules in **162ms**.

## Summary

Expand Down
43 changes: 43 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,49 @@ When this is a false positive:

Confidence: **0.60–0.80**, scaling with run length. This rule is intentionally conservative.

## `long-parameter-list`

Flags functions with too many parameters or multiple boolean flag parameters.

Default thresholds:

- `long-parameter-list.maxParams`: `5`
- `long-parameter-list.maxBooleans`: `2`

When this is a false positive:

- framework-conventional signatures such as `(props)` or `(state, action)`
- generated or adapter glue that must mirror an external API

## `god-file`

Flags kitchen-sink modules that exceed multiple independent sprawl thresholds together (size, exports, top-level declarations, mixed concerns).

Default thresholds:

- `god-file.maxLines`: `400`
- `god-file.maxExports`: `10`
- `god-file.maxTopLevelDecls`: `12`
- `god-file.minAxes`: `3`

When this is a false positive:

- large but cohesive single-purpose utility modules
- generated index files (pair with `barrel-file` guidance)

## `cognitive-complexity`

Scores functions with a Sonar-style cognitive complexity model that penalizes nesting more than flat branching.

Default thresholds:

- `cognitive-complexity.max`: `15`

When this is a false positive:

- generated parsers or dispatch tables with intentionally flat `switch` blocks
- compare with `complex-control-flow` when only cyclomatic count is high

## `python-error-handling`

Flags Python `try/except` handlers that are empty (`pass` only), bare `except:`, or broad `except Exception:` blocks that only log without meaningful handling.
Expand Down
100 changes: 100 additions & 0 deletions schema/debtlens.config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@
"swallowed-error",
"floating-promise",
"commented-out-code",
"long-parameter-list",
"god-file",
"cognitive-complexity",
"api-surface-sprawl",
"story-only-component",
"python-todo-comment",
Expand Down Expand Up @@ -341,6 +344,27 @@
"commented-out-code.maxPerFile": {
"type": "number"
},
"long-parameter-list.maxParams": {
"type": "number"
},
"long-parameter-list.maxBooleans": {
"type": "number"
},
"god-file.maxLines": {
"type": "number"
},
"god-file.maxExports": {
"type": "number"
},
"god-file.maxTopLevelDecls": {
"type": "number"
},
"god-file.minAxes": {
"type": "number"
},
"cognitive-complexity.max": {
"type": "number"
},
"swiftui-large-view.maxLines": {
"type": "number"
},
Expand Down Expand Up @@ -602,6 +626,30 @@
"high"
]
},
"long-parameter-list": {
"enum": [
"info",
"low",
"medium",
"high"
]
},
"god-file": {
"enum": [
"info",
"low",
"medium",
"high"
]
},
"cognitive-complexity": {
"enum": [
"info",
"low",
"medium",
"high"
]
},
"api-surface-sprawl": {
"enum": [
"info",
Expand Down Expand Up @@ -1044,6 +1092,21 @@
"minimum": 0,
"maximum": 1
},
"long-parameter-list": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"god-file": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"cognitive-complexity": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"api-surface-sprawl": {
"type": "number",
"minimum": 0,
Expand Down Expand Up @@ -1341,6 +1404,43 @@
"legacy-baseline"
],
"description": "Named quality-gate rollout preset. Explicit CLI flags override preset defaults."
},
"budgets": {
"type": "object",
"description": "Per-path debt budgets. Keys are path globs; values cap issue counts per area.",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"maxIssues": {
"type": "integer",
"minimum": 0
},
"maxHigh": {
"type": "integer",
"minimum": 0
},
"maxMedium": {
"type": "integer",
"minimum": 0
}
}
}
},
"badge": {
"type": "object",
"description": "Color thresholds for badge output.",
"additionalProperties": false,
"properties": {
"greenMax": {
"type": "integer",
"minimum": 0
},
"yellowMax": {
"type": "integer",
"minimum": 0
}
}
}
}
}
52 changes: 52 additions & 0 deletions src/cli/calibrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { loadEffectiveConfig } from "../config/loadConfig.js";
import { mergeConfig } from "../config/mergeConfig.js";
import { mergeDebtLensConfig } from "../config/loadConfig.js";
import { buildCalibrateSuggestions, renderCalibrateReport } from "../core/calibrate.js";
import { scan } from "../core/scan.js";
import { parseSeverity } from "../core/severity.js";
import { loadConfiguredPlugins } from "./scanPipeline.js";
import { parseCommaList, parseInteger, parseRuleList, parseThresholds } from "./parse.js";

export interface CalibrateInput {
target: string;
cwd: string;
configPath?: string;
percentile?: number;
write?: boolean;
cliOptions?: Record<string, unknown>;
}

export async function runCalibrate(input: CalibrateInput): Promise<string> {
const cwd = resolve(input.cwd);
const effectiveConfig = loadEffectiveConfig(cwd, input.configPath);
const pluginContribution = await loadConfiguredPlugins(cwd, input.cliOptions ?? {}, effectiveConfig.config, effectiveConfig.pluginConfigDir);
const options = mergeConfig(input.target, effectiveConfig.config, {
cwd,
include: parseCommaList(input.cliOptions?.include as string | undefined),
exclude: parseCommaList(input.cliOptions?.exclude as string | undefined),
rules: parseRuleList(input.cliOptions?.rules as string | undefined),
pack: input.cliOptions?.pack ? String(input.cliOptions.pack) : undefined,
thresholds: parseThresholds(input.cliOptions?.threshold as string | undefined),
minSeverity: parseSeverity(String(input.cliOptions?.minSeverity ?? "low"), "low"),
maxFiles: input.cliOptions?.maxFiles as number | undefined,
respectGitignore: input.cliOptions?.respectGitignore === true ? true : undefined,
pluginDetectors: pluginContribution?.detectors,
pluginThresholds: pluginContribution?.thresholds,
pluginVocabulary: pluginContribution?.vocabulary,
});
const result = await scan(options);
const calibrate = buildCalibrateSuggestions(result, options, {
percentile: input.percentile ?? 90,
});
if (input.write && calibrate.suggestions.length > 0) {
const configPath = resolve(cwd, input.configPath ?? "debtlens.config.json");
const existing = effectiveConfig.config;
const merged = mergeDebtLensConfig(existing, {
thresholds: Object.fromEntries(calibrate.suggestions.map((suggestion) => [suggestion.key, suggestion.suggested])),
});
writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
}
return renderCalibrateReport(calibrate);
}
31 changes: 31 additions & 0 deletions src/cli/commands/calibrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Command } from "commander";
import { resolve } from "node:path";
import { runCalibrate } from "../calibrate.js";
import { parseInteger } from "../parse.js";

export function registerCalibrateCommand(program: Command): void {
program.command("calibrate")
.description("Suggest percentile-based threshold overrides from the current codebase.")
.argument("[target]", "directory or file to scan", ".")
.option("--cwd <path>", "working directory", process.cwd())
.option("--config <path>", "path to debtlens.config.json")
.option("--percentile <count>", "percentile used for suggestions (50-99)", parseInteger)
.option("--write", "merge suggested thresholds into debtlens.config.json")
.action(async (target: string, rawOptions: Record<string, unknown>) => {
try {
const report = await runCalibrate({
target,
cwd: resolve(String(rawOptions.cwd ?? process.cwd())),
configPath: rawOptions.config ? String(rawOptions.config) : undefined,
percentile: rawOptions.percentile as number | undefined,
write: rawOptions.write === true,
cliOptions: rawOptions,
});
process.stdout.write(report);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`DebtLens failed: ${message}\n`);
process.exitCode = 1;
}
});
}
63 changes: 63 additions & 0 deletions src/cli/commands/history.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { Command } from "commander";
import { resolve } from "node:path";
import { renderHistoryReport, type HistoryFormat } from "../../reporters/historyReporter.js";
import { parseInteger } from "../parse.js";
import { runHistoryRecord, runHistoryShow } from "../history.js";

export function registerHistoryCommand(program: Command): void {
const history = program.command("history")
.description("Record and view maintainability debt trends over time.");

history.command("record")
.description("Scan and append a summary snapshot to the history ledger.")
.argument("[target]", "directory or file to scan", ".")
.option("--cwd <path>", "working directory", process.cwd())
.option("--config <path>", "path to debtlens.config.json")
.option("--history-path <path>", "history ledger path", ".debtlens/history.jsonl")
.option("--once", "skip recording when the current git SHA already exists")
.action(async (target: string, rawOptions: Record<string, unknown>) => {
try {
const { appended, path } = await runHistoryRecord({
target,
cwd: resolve(String(rawOptions.cwd ?? process.cwd())),
configPath: rawOptions.config ? String(rawOptions.config) : undefined,
historyPath: rawOptions.historyPath ? String(rawOptions.historyPath) : undefined,
once: rawOptions.once === true,
cliOptions: rawOptions,
});
process.stdout.write(`${appended ? "Recorded" : "Skipped"} history entry at ${path}\n`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`DebtLens failed: ${message}\n`);
process.exitCode = 1;
}
});

history.command("show")
.description("Render the history ledger as a timeline.")
.option("--cwd <path>", "working directory", process.cwd())
.option("--history-path <path>", "history ledger path", ".debtlens/history.jsonl")
.option("--format <format>", "terminal, markdown, html, or json", "terminal")
.option("--since <sha|date>", "only include entries since this git SHA prefix or ISO date")
.option("--limit <count>", "limit to the most recent N entries", parseInteger)
.action((rawOptions: Record<string, unknown>) => {
try {
const format = parseHistoryFormat(String(rawOptions.format ?? "terminal"));
const entries = runHistoryShow(resolve(String(rawOptions.cwd ?? process.cwd())), {
historyPath: rawOptions.historyPath ? String(rawOptions.historyPath) : undefined,
since: rawOptions.since ? String(rawOptions.since) : undefined,
limit: rawOptions.limit as number | undefined,
});
process.stdout.write(renderHistoryReport(entries, format));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`DebtLens failed: ${message}\n`);
process.exitCode = 1;
}
});
}

function parseHistoryFormat(value: string): HistoryFormat {
if (value === "terminal" || value === "markdown" || value === "html" || value === "json") return value;
throw new Error(`Invalid history format "${value}". Expected terminal, markdown, html, or json.`);
}
Loading