Skip to content
Open
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: 8 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,21 @@ 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.
- Payoff ranking via `--sort payoff`, JSON `payoffScore`, and top-payoff report sections
([#251](https://github.com/ColumbusLabs/DebtLens/issues/251)).
- `debtlens calibrate` for percentile-based threshold suggestions with optional `--write`
([#256](https://github.com/ColumbusLabs/DebtLens/issues/256)).
- Interactive `debtlens triage` for keep/baseline/suppress workflows
([#255](https://github.com/ColumbusLabs/DebtLens/issues/255)).
- `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
design smells beyond single-axis size checks.
- Config `budgets` block and `debtlens scan --budget-report` for per-area debt SLO gating.
- Config `budgets` block and `debtlens scan --budget-report` for per-area debt SLO gating
([#252](https://github.com/ColumbusLabs/DebtLens/issues/252)).
- `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
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ debtlens init --pack core # starter config using the core rule pack preset
debtlens init --policy @org/debtlens-policy # starter config from a shared policy package
debtlens init --from-eslint eslint.config.json # print a migration suggestion without writing
debtlens adopt # adoption report (dry run; recommends minSeverity)
debtlens calibrate . # suggest percentile-based threshold overrides
debtlens triage . # interactively keep, baseline, or suppress findings
debtlens watch examples/react --rules todo-comment # rescan on file changes
debtlens completions zsh # print shell completions
debtlens mcp # stdio MCP server for Cursor/Claude-style agents
Expand Down Expand Up @@ -343,6 +345,39 @@ The second command writes `debtlens.config.json` and `debtlens-baseline.json` (b

For serious open-source repositories and broad monorepos, treat adoption as a scoped rollout instead of a whole-repo gate on day one. Run `adopt` first, then start with `--changed origin/main` or a maintained source subdirectory, add `--package` for workspaces, keep generated/dependency outputs excluded, and narrow expensive or noisy rules with `--rules`, thresholds, baselines, or confidence floors before widening coverage. If the default 2,000-file cap appears, either raise it with `--max-files` for a deliberate full scan or make the target more precise with `--package`, `--include`, `--exclude`, `--changed`, or `--respect-gitignore`.

### Tune, prioritize, triage, and budget

After `adopt` picks rules, use these commands to finish a low-noise rollout:

```bash
# 1. Tune numeric thresholds to your repo's distributions
debtlens calibrate . --percentile 90
debtlens calibrate . --percentile 90 --write # merge into debtlens.config.json

# 2. Review highest-ROI findings first (enable hotspots when git history is available)
debtlens scan . --sort payoff --hotspots

# 3. Walk the backlog interactively (requires a TTY; use --dry-run to preview)
debtlens triage .

# 4. Protect cleaned areas with per-path budgets in debtlens.config.json
debtlens scan . --budget-report
debtlens scan . --fail-on high # budget breaches also fail the gate
```

Example `budgets` block:

```json
{
"budgets": {
"src/payments": { "maxIssues": 20, "maxHigh": 0 },
"src/legacy": { "maxIssues": 250 }
}
}
```

See [`docs/prioritization.md`](./docs/prioritization.md) for the payoff formula, `priority` weights, and budget glob matching.

Named quality-gate presets give teams a shared rollout vocabulary:

| Preset | Use when | Default behavior |
Expand Down
24 changes: 24 additions & 0 deletions docs/false-positives.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,30 @@ adding inline suppressions.
| Low-confidence finding family | `ruleConfidenceFloors` or `--fail-on-confidence` | Keeps findings visible while preventing weak gates. |
| One documented exception | Inline suppression with a reason | Auditable, local, and visible in JSON/SARIF output. |

## Calibrate thresholds to your repo

Default thresholds are deliberately generic. On a large legacy repo they can be noisy; on a small repo they can miss real debt. After `debtlens adopt` picks rules and severity, run calibration to tune the numbers:

```bash
# Preview percentile-based threshold suggestions (default p90)
debtlens calibrate .

# Tune aggressiveness and merge suggestions into debtlens.config.json
debtlens calibrate . --percentile 85 --write
```

Calibration scans the target, collects observed metrics for threshold-driven rules (function length, branch counts, and similar), and suggests values at the chosen percentile so roughly the worst N% is flagged. Review the printed config snippet before using `--write`; unrelated config keys are preserved.

Pair calibration with payoff ranking and triage for a low-noise first rollout:

```bash
debtlens calibrate . --percentile 90
debtlens scan . --sort payoff --hotspots
debtlens triage .
```

See [`docs/prioritization.md`](./prioritization.md) for payoff scoring and per-area budgets.

## Baseline before suppressing

```bash
Expand Down
85 changes: 85 additions & 0 deletions docs/prioritization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Payoff prioritization

DebtLens can rank findings by **payoff score** so teams fix the debt that costs the most first instead of working through a flat severity list.

## When scores appear

Each issue gets a `payoffScore` in JSON output when payoff ranking is enabled:

- pass `--sort payoff` on `debtlens scan`, or
- enable git churn hotspots (`hotspots: true` in config or `--hotspots` on the CLI), or
- pass `--blame-age` to include age in the score.

Terminal, Markdown, and HTML reports include a **Top payoff targets** section when any issue carries a score.

## Scoring formula

Payoff combines signals already present on each finding:

```
payoffScore = severityWeight × confidence × churnFactor × ageFactor
```

| Factor | Default behavior |
| --- | --- |
| `severityWeight` | high 16, medium 8, low 3, info 1 |
| `confidence` | issue confidence, floored at 0.35 |
| `churnFactor` | `1 + log2(1 + churnMetric) × churnWeight` when hotspot data exists; otherwise 1 |
| `ageFactor` | `1 + min(introducedDaysAgo / 365, 2) × ageWeight` when blame age is available; otherwise 1 |

Churn metrics come from [`src/core/hotspots.ts`](../src/core/hotspots.ts). Age uses optional `introducedDaysAgo` from `--blame-age`.

## CLI usage

```bash
# Sort findings by payoff and show top targets in the terminal report
debtlens scan . --sort payoff --hotspots

# JSON consumers read payoffScore on each issue
debtlens scan . --sort payoff --format json
```

Use `--hotspots` (or config `hotspots: true`) when git history is available so churn boosts files that change often. In CI, check out enough history (`fetch-depth: 0` or a bounded `--churn-range`).

## Config weights

Tune weights under the `priority` block in `debtlens.config.json`:

```json
{
"priority": {
"severity": { "high": 20, "medium": 10, "low": 4, "info": 1 },
"churn": 1.2,
"age": 0.75
}
}
```

Higher `churn` and `age` weights amplify those factors. Severity weights follow the same keys as built-in severities.

## Adoption workflow

Payoff ranking fits between calibration and triage:

1. `debtlens calibrate` — tune thresholds to your repo (see [`docs/false-positives.md`](./false-positives.md)).
2. `debtlens scan . --sort payoff --hotspots` — review the highest-ROI findings first.
3. `debtlens triage` — baseline or suppress the backlog interactively.
4. Add `budgets` — cap debt per directory after cleanup (see below).

## Per-area budgets

After triage, protect cleaned areas with path budgets:

```json
{
"budgets": {
"src/payments": { "maxIssues": 20, "maxHigh": 0 },
"src/legacy": { "maxIssues": 250 }
}
}
```

- Budget breaches fail the scan gate (exit code 1) like `--fail-on`.
- `debtlens scan --budget-report` prints used/budget/headroom without failing — useful in CI dashboards.

Keys are path globs (`src/payments/**`, `src/**/*.ts`, or exact prefixes).
3 changes: 3 additions & 0 deletions src/cli/commands/calibrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export function registerCalibrateCommand(program: Command): void {
.argument("[target]", "directory or file to scan", ".")
.option("--cwd <path>", "working directory", process.cwd())
.option("--config <path>", "path to debtlens.config.json")
.option("--pack <name>", "rule pack preset to scan with")
.option("--rules <rules>", "comma-separated rule ids to run")
.option("--threshold <thresholds>", "comma-separated key=value threshold overrides")
.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>) => {
Expand Down
58 changes: 47 additions & 11 deletions src/cli/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface TriageInput {
cliOptions?: Record<string, unknown>;
input?: NodeJS.ReadableStream;
output?: NodeJS.WritableStream;
/** Injectable prompt for tests; defaults to readline when omitted. */
ask?: (message: string) => Promise<string>;
}

export interface TriageActionResult {
Expand Down Expand Up @@ -56,18 +58,22 @@ export async function runTriage(input: TriageInput): Promise<TriageActionResult>
const suppressions: string[] = [];
const counts: TriageActionResult = { kept: 0, baselined: 0, suppressed: 0, skipped: 0 };

const rl = createInterface({
input: input.input ?? process.stdin,
output: input.output ?? process.stdout,
});
const output = input.output ?? process.stdout;
const rl = input.ask
? undefined
: createInterface({
input: input.input ?? process.stdin,
output,
});
const ask = input.ask ?? ((message: string) => rl!.question(message));

try {
for (let index = 0; index < issues.length; index += 1) {
const issue = issues[index];
if (!issue) continue;
const rendered = formatIssue(issue, index + 1, issues.length);
process.stdout.write(`\n${rendered}\n`);
const rawAnswer = (await rl.question("Action [k]eep [b]aseline [s]uppress [n]ext [q]uit [B]atch rule: ")).trim();
output.write(`\n${rendered}\n`);
const rawAnswer = (await ask("Action [k]eep [b]aseline [s]uppress [n]ext [q]uit [B]atch rule: ")).trim();
const answer = rawAnswer.toLowerCase();

if (answer === "q" || answer === "quit") break;
Expand All @@ -76,7 +82,10 @@ export async function runTriage(input: TriageInput): Promise<TriageActionResult>
continue;
}
if (rawAnswer === "B" || answer === "batch" || answer === "b-rule") {
const batchAction = (await rl.question("Apply to all remaining findings of this rule with [k]eep [b]aseline [s]uppress? ")).trim().toLowerCase();
const batchAction = (await ask("Apply to all remaining findings of this rule with [k]eep [b]aseline [s]uppress? ")).trim().toLowerCase();
const suppressReason = isSuppressAction(batchAction)
? await promptSuppressReason(ask, output)
: undefined;
for (let cursor = index; cursor < issues.length; cursor += 1) {
const candidate = issues[cursor];
if (!candidate || candidate.ruleId !== issue.ruleId) continue;
Expand All @@ -86,29 +95,37 @@ export async function runTriage(input: TriageInput): Promise<TriageActionResult>
fingerprints,
suppressions,
counts,
suppressReason,
});
}
break;
}

const suppressReason = isSuppressAction(answer)
? await promptSuppressReason(ask, output)
: undefined;
applyTriageAction(issue, answer, {
dryRun: input.dryRun,
baseline,
fingerprints,
suppressions,
counts,
suppressReason,
});
}
} finally {
rl.close();
rl?.close();
}

if (!input.dryRun) {
writeBaseline(cwd, baselinePath, baseline);
if (suppressions.length > 0) {
process.stdout.write("\nSuggested suppression directives:\n");
for (const directive of suppressions) process.stdout.write(directive);
output.write("\nSuggested suppression directives:\n");
for (const directive of suppressions) output.write(directive);
}
} else if (suppressions.length > 0) {
output.write("\nSuggested suppression directives (dry run):\n");
for (const directive of suppressions) output.write(directive);
}

return counts;
Expand All @@ -123,6 +140,7 @@ function applyTriageAction(
fingerprints: Set<string>;
suppressions: string[];
counts: TriageActionResult;
suppressReason?: string;
},
): void {
const fingerprint = issue.fingerprint ?? issue.id;
Expand All @@ -133,14 +151,32 @@ function applyTriageAction(
return;
}
if (action === "s" || action === "suppress") {
const reason = "triaged via debtlens triage";
const reason = context.suppressReason?.trim();
if (!reason) {
throw new Error("Suppression reason is required.");
}
context.suppressions.push(runSuppress({ ruleId: issue.ruleId, reason }));
context.counts.suppressed += 1;
return;
}
context.counts.kept += 1;
}

function isSuppressAction(action: string): boolean {
return action === "s" || action === "suppress";
}

async function promptSuppressReason(
ask: (message: string) => Promise<string>,
output: NodeJS.WritableStream,
): Promise<string> {
while (true) {
const reason = (await ask("Suppression reason (required): ")).trim();
if (reason) return reason;
output.write("A reason is required for suppressions.\n");
}
}

function formatIssue(issue: DebtIssue, index: number, total: number): string {
const location = issue.location ? `${issue.file}:${issue.location.startLine}` : issue.file;
return [
Expand Down
Loading