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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ Options:
--sarif-category <category> SARIF only: set runs[].automationDetails.id
--junit-fail-on <severity> JUnit only: failed testcase severity threshold
--markdown-heatmap [limit] Markdown only: append a debt heatmap table
--top <count> show only the top N payoff-ranked findings; gates use the full scan
--pr-comment-max-findings <count> PR comment only: cap detailed findings
--pr-comment-max-bytes <count> PR comment only: cap rendered body bytes
--pr-comment-full-report-url <url> PR comment only: link omitted findings to a full report
Expand Down Expand Up @@ -284,6 +285,10 @@ debtlens scan --format gitlab-codequality --output gl-code-quality-report.json
# Package-scoped adoption report in a workspace
debtlens adopt . --package web --format markdown

# Keep a first evaluation focused on the ten highest-signal findings
debtlens adopt . --top 10
debtlens scan . --top 10 --format markdown

# CI gate: allow low/medium debt but fail high-confidence high-severity debt
debtlens scan --min-severity medium --fail-on high --fail-on-confidence 0.8

Expand Down Expand Up @@ -407,6 +412,12 @@ When matched files exceed `maxFiles`, DebtLens scans the first selected files, p

Every reported issue includes a line-stable `fingerprint`. Inline suppressions with reasons are exported at the root `suppressions` array so compliance and CI consumers can audit what was hidden. Pass `--audit-suppressions` to also export `suppressionDirectives`, a directive-level audit of used, unused, and not-evaluated inline suppressions with file, line, rule, reason, hidden-finding count, and recommended action. When a baseline or `--diff-base` is used, `summary.deltaFromBaseline` reports new, resolved, changed, total, and per-rule count deltas. JSON and Markdown reports also surface `summary.correlations` for files where multiple rules cluster together.

Pass `--top N` to render a deterministic payoff-ranked view for a noisy first run.
Terminal, Markdown, PR-comment, and JSON output contain only the selected findings and
include `summary.issueSelection` with the requested limit, full available count, and
omitted count. Summary counts remain consistent with the selected `issues` array.
Quality gates, area budgets, and baseline writes still evaluate the complete scan.

Use `debtlens compare previous.json current.json --format terminal|markdown|json` to compare two ScanResult JSON files without rescanning. The compare report includes total, severity, and rule deltas; when both inputs contain issue arrays, it also reports exact new, resolved, changed, severity-regression, and top-new-file counts. Run compare with the same scan scope and options for meaningful trends.

Pass `--blame-age` to enrich JSON issues with optional `introducedDaysAgo` metadata from
Expand Down Expand Up @@ -647,7 +658,7 @@ guidance.

## Output formats

Terminal output is designed for local development. JSON is designed for integrations. Markdown is designed for release notes and maintainer handoffs. `pr-comment` is compact Markdown with prioritized fix targets, collapsible per-file sections, and optional caps for GitHub pull request comments. SARIF (2.1.0) is designed for GitHub code scanning and other security/quality dashboards; findings include stable SARIF `partialFingerprints`, and `--sarif-category` can set `runs[].automationDetails.id` for package or pack-separated uploads. HTML is a self-contained human report. JUnit XML is for CI systems that expect test-style failures; `--junit-fail-on` can keep lower-severity findings visible as skipped testcases while only the selected severity threshold fails the suite. When omitted, every reported finding fails to preserve existing behavior. `gitlab-codequality` emits GitLab's Code Quality JSON array with stable fingerprints, repo-relative paths, lines, descriptions, rule names, and mapped severities.
Terminal output is designed for local development. JSON is designed for integrations. Markdown is designed for release notes and maintainer handoffs. `pr-comment` is compact Markdown with prioritized fix targets, collapsible per-file sections, and optional caps for GitHub pull request comments. When payoff scores are present, capped detailed findings are selected by payoff before omitted findings are summarized. SARIF (2.1.0) is designed for GitHub code scanning and other security/quality dashboards; findings include stable SARIF `partialFingerprints`, and `--sarif-category` can set `runs[].automationDetails.id` for package or pack-separated uploads. HTML is a self-contained human report. JUnit XML is for CI systems that expect test-style failures; `--junit-fail-on` can keep lower-severity findings visible as skipped testcases while only the selected severity threshold fails the suite. When omitted, every reported finding fails to preserve existing behavior. `gitlab-codequality` emits GitLab's Code Quality JSON array with stable fingerprints, repo-relative paths, lines, descriptions, rule names, and mapped severities.

```bash
debtlens scan --format json
Expand Down Expand Up @@ -868,6 +879,11 @@ For agent integrations, see the [MCP server setup](./docs/mcp.md).

Set `comment: true` to upsert a stable pull request comment (requires `pull-requests: write`). Comment posting is warn-only by default so forked or permission-limited pull requests can still produce artifacts and annotations; set `comment-fail-on-error: true` when a missing comment should fail the Action.

For low-noise pull request feedback, pair `diff-base` (or `baseline`) with
`comment-delta-only: true` and `comment-max-findings: 20`. This reports only new
findings and selects the capped detail by payoff score. Existing workflows remain
uncapped unless they opt in; see the [GitHub CI upgrade guidance](./docs/ci-github.md#low-noise-pull-request-comments).

```yaml
permissions:
contents: read
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ inputs:
description: Optional previous ScanResult JSON path for step-summary trend comparison.
default: ""
comment-delta-only:
description: PR comment only - emphasize baseline or diff-base delta findings.
description: PR comment only - label findings as baseline or diff-base deltas. Pair with baseline or diff-base so the scan contains only new findings.
default: "false"
step-summary:
description: Append a compact Markdown summary to the GitHub Actions step summary.
Expand Down
28 changes: 28 additions & 0 deletions docs/ci-github.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,33 @@ the baseline:
diff-base: origin/${{ github.base_ref }}
comment: true
comment-delta-only: true
comment-max-findings: 20
step-summary: true
```

## Low-noise pull request comments

For new or upgraded comment workflows, the recommended mode is delta-only feedback
capped to the top 20 findings:

```yaml
- uses: ColumbusLabs/debtlens@v0
with:
diff-base: origin/${{ github.base_ref }}
comment: true
comment-delta-only: true
comment-max-findings: 20
comment-max-bytes: 60000
```

`diff-base` (or `baseline`) makes the scan contain only findings absent from the
comparison. `comment-delta-only` labels that comparison clearly in the comment, while
`comment-max-findings` limits detailed annotations. Detailed findings are selected by
payoff score before the cap; omitted severity, rule, and file totals remain summarized.
If the comparison contains no new findings, the comment reports the empty delta instead
of implying that the repository has no maintainability debt.

Existing workflows keep their current behavior because `comment-delta-only` remains
`false` and `comment-max-findings` remains uncapped unless configured. To adopt the
low-noise mode, add a baseline or `diff-base` first, then enable both comment inputs
above. The independent 60,000-byte safety cap remains enabled by default.
12 changes: 11 additions & 1 deletion docs/prioritization.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ DebtLens can rank findings by **payoff score** so teams fix the debt that costs
Each issue gets a `payoffScore` in JSON output when payoff ranking is enabled:

- pass `--sort payoff` on `debtlens scan`, or
- pass `--top N` to render a bounded highest-signal view, or
- enable git churn hotspots with `--hotspots` on the CLI, or
- pass `--blame-age` to include age in the score.

Expand Down Expand Up @@ -37,13 +38,22 @@ debtlens scan . --sort payoff --hotspots

# JSON consumers read payoffScore and a bounded top-target shortlist
debtlens scan . --sort payoff --format json

# Show only the ten highest-signal findings without weakening gates
debtlens scan . --top 10 --format markdown
```

When payoff scores are enabled, JSON output also includes
`summary.topPayoffTargets`. It contains at most 10 compact targets ordered
deterministically by score, file, line, rule, and fingerprint. Full issue details
remain in `issues`.

`--top N` is a presentation limit applied after baseline or diff filtering. Its JSON
view keeps `summary.totalIssues` consistent with the selected `issues` array and adds
`summary.issueSelection` with `limit`, `totalAvailable`, and `omitted`. Scanning,
`--fail-on`, regression gates, area budgets, and `--write-baseline` still operate on
the complete result, including findings omitted from the rendered view.

Use `--hotspots` 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
Expand All @@ -67,7 +77,7 @@ Higher `churn` and `age` weights amplify those factors. Severity weights follow
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.
2. `debtlens adopt . --top 10` or `debtlens scan . --top 10 --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).

Expand Down
7 changes: 4 additions & 3 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ npx --yes --package=debtlens@latest debtlens scan . --pack core,python-web,vue,s
## 3. Get an adoption plan

```bash
npx --yes --package=debtlens@latest debtlens adopt . --format markdown
npx --yes --package=debtlens@latest debtlens adopt . --top 10 --format markdown
```

Use the recommendation to decide whether the first CI run should be advisory, baseline
only, or a high-severity gate. The named presets are the shortest way to express that
choice:
only, or a high-severity gate. `--top 10` keeps the report focused without weakening
the scan; baseline writes and later gates always evaluate every finding. The named
presets are the shortest way to express that choice:

```bash
npx --yes --package=debtlens@latest debtlens scan . --gate advisory
Expand Down
27 changes: 27 additions & 0 deletions schema/debtlens.scan-result.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,33 @@
}
}
},
"issueSelection": {
"type": "object",
"additionalProperties": false,
"required": [
"strategy",
"limit",
"totalAvailable",
"omitted"
],
"properties": {
"strategy": {
"const": "payoff"
},
"limit": {
"type": "integer",
"minimum": 1
},
"totalAvailable": {
"type": "integer",
"minimum": 0
},
"omitted": {
"type": "integer",
"minimum": 0
}
}
},
"warnings": {
"type": "array",
"items": {
Expand Down
42 changes: 39 additions & 3 deletions src/cli/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import { findWorkspaceRoot, listWorkspacePackages, resolveWorkspacePackage, type
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, DebtLensConfig, ScanResult, Severity } from "../core/types.js";
import type { CliOptions, DebtIssue, DebtLensConfig, ScanResult, Severity } from "../core/types.js";
import { isGitRepo } from "../utils/git.js";
import { enrichIssuesWithPayoffScores, topPayoffIssues } from "../core/priority.js";
import { buildThresholdSuggestions, type ThresholdSuggestion } from "./adoptionThresholds.js";
import {
formatGatePresetDefaults,
Expand All @@ -29,6 +30,7 @@ export interface AdoptInput {
packageName?: string;
writeBaseline?: boolean | string;
format?: "terminal" | "markdown";
topFindings?: number;
}

export interface AdoptResult {
Expand Down Expand Up @@ -64,6 +66,7 @@ export function formatAdoptReport(
thresholdSuggestions: ThresholdSuggestion[] = [],
rolloutPlan: RolloutPlanStep[] = [],
gatePreset?: GatePreset,
topIssues: DebtIssue[] = [],
): string {
const { summary } = scanResult;
const topRules = Object.entries(summary.byRule)
Expand All @@ -87,6 +90,7 @@ export function formatAdoptReport(
`Recommended minSeverity: ${recommendedMinSeverity}`,
`Gate preset: ${formatGatePresetSummary(gatePreset)}`,
];
renderTopFindingsTerminal(lines, topIssues, summary.totalIssues);
if (thresholdSuggestions.length > 0) {
lines.push("", "Suggested threshold tuning:");
for (const suggestion of thresholdSuggestions) {
Expand Down Expand Up @@ -114,6 +118,7 @@ export function formatAdoptMarkdownReport(
thresholdSuggestions: ThresholdSuggestion[] = [],
rolloutPlan: RolloutPlanStep[] = [],
gatePreset?: GatePreset,
topIssues: DebtIssue[] = [],
): string {
const { summary } = scanResult;
const topRules = Object.entries(summary.byRule)
Expand Down Expand Up @@ -142,6 +147,7 @@ export function formatAdoptMarkdownReport(
`Recommended minSeverity: **${recommendedMinSeverity}**`,
`Gate preset: **${gatePreset ?? "(none)"}**${gatePreset ? ` - ${formatGatePresetDefaults(gatePreset) || "advisory only"}` : ""}`,
];
renderTopFindingsMarkdown(lines, topIssues, summary.totalIssues);
if (thresholdSuggestions.length > 0) {
lines.push(
"",
Expand Down Expand Up @@ -173,6 +179,9 @@ export async function runAdopt(input: AdoptInput): Promise<AdoptResult> {
: input.target;
const options = mergeConfig(target, fileConfig, input.cliOptions);
const result = await scan(options);
const topIssues = input.topFindings
? buildTopAdoptionIssues(result, input.topFindings, fileConfig)
: [];

const recommended = recommendMinSeverity(result.summary.bySeverity, result.summary.totalIssues);
const thresholdSuggestions = buildThresholdSuggestions(result, options);
Expand All @@ -193,8 +202,8 @@ export async function runAdopt(input: AdoptInput): Promise<AdoptResult> {
}

lines.push((input.format === "markdown"
? formatAdoptMarkdownReport(result, recommended, thresholdSuggestions, rolloutPlan, selectedGatePreset)
: formatAdoptReport(result, recommended, thresholdSuggestions, rolloutPlan, selectedGatePreset)).trimEnd());
? formatAdoptMarkdownReport(result, recommended, thresholdSuggestions, rolloutPlan, selectedGatePreset, topIssues)
: formatAdoptReport(result, recommended, thresholdSuggestions, rolloutPlan, selectedGatePreset, topIssues)).trimEnd());

let configWritten: string | undefined;
let baselineWritten: string | undefined;
Expand Down Expand Up @@ -348,6 +357,9 @@ function buildScopedCommandArgs(command: "adopt" | "scan", input: AdoptInput, fi
addListArg(args, "--include", input.cliOptions.include);
addListArg(args, "--exclude", input.cliOptions.exclude);
addThresholdArg(args, input.cliOptions.thresholds);
if (command === "adopt" && input.topFindings) {
args.push("--top", String(input.topFindings));
}
if (command === "adopt" && input.format === "markdown") {
args.push("--format", "markdown");
}
Expand Down Expand Up @@ -414,3 +426,27 @@ function shellQuote(value: string): string {
function plural(count: number, word: string): string {
return `${word}${count === 1 ? "" : "s"}`;
}

function buildTopAdoptionIssues(result: ScanResult, limit: number, fileConfig: DebtLensConfig): DebtIssue[] {
enrichIssuesWithPayoffScores(result.issues, { weights: fileConfig.priority });
return topPayoffIssues(result.issues, limit);
}

function renderTopFindingsTerminal(lines: string[], issues: DebtIssue[], totalIssues: number): void {
if (issues.length === 0) return;
lines.push("", `Highest-signal findings (${issues.length} of ${totalIssues}):`);
for (const issue of issues) {
const location = issue.location ? `${issue.file}:${issue.location.startLine}` : issue.file;
lines.push(` ${issue.payoffScore?.toFixed(2)} [${issue.severity}] ${issue.ruleName} — ${location}`);
lines.push(` ${issue.message}`);
}
}

function renderTopFindingsMarkdown(lines: string[], issues: DebtIssue[], totalIssues: number): void {
if (issues.length === 0) return;
lines.push("", `## Highest-signal findings (${issues.length} of ${totalIssues})`, "");
for (const issue of issues) {
const location = issue.location ? `${issue.file}:${issue.location.startLine}` : issue.file;
lines.push(`- **${issue.payoffScore?.toFixed(2)}** [${issue.severity}] \`${issue.ruleId}\` — \`${location}\` — ${issue.message}`);
}
}
2 changes: 2 additions & 0 deletions src/cli/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const SCAN_ARG_FLAGS = [
"--sarif-category",
"--junit-fail-on",
"--markdown-heatmap",
"--top",
] as const;

export function buildScanArgv(target: string, rawOptions: Record<string, unknown>): string[] {
Expand Down Expand Up @@ -83,6 +84,7 @@ export function buildScanArgv(target: string, rawOptions: Record<string, unknown
addString(args, "--sarif-category", rawOptions.sarifCategory);
addString(args, "--junit-fail-on", rawOptions.junitFailOn);
addOptionalValue(args, "--markdown-heatmap", rawOptions.markdownHeatmap);
addValue(args, "--top", rawOptions.top);
return args;
}

Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/adopt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function registerAdoptCommand(program: Command): void {
.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)")
.option("--top <count>", "show the top N payoff-ranked findings in the adoption report", parseInteger)
.option("--format <format>", "terminal or markdown", "terminal")
.action(async (target: string, rawOptions: Record<string, unknown>) => {
try {
Expand All @@ -41,6 +42,7 @@ export function registerAdoptCommand(program: Command): void {
writeConfig: rawOptions.writeConfig === true,
force: rawOptions.force === true,
writeBaseline: rawOptions.writeBaseline as boolean | string | undefined,
topFindings: rawOptions.top as number | undefined,
cliOptions: {
cwd,
include: parseCommaList(rawOptions.include as string | undefined),
Expand Down
Loading