From 9880710dd25ecb6854d5bf047f6e19e422a56340 Mon Sep 17 00:00:00 2001 From: ColumbusLabs <287001685+ColumbusLabs@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:33:03 -0400 Subject: [PATCH] feat: add low-noise payoff-ranked findings --- README.md | 18 ++++- action.yml | 2 +- docs/ci-github.md | 28 +++++++ docs/prioritization.md | 12 ++- docs/quickstart.md | 7 +- schema/debtlens.scan-result.schema.json | 27 +++++++ src/cli/adopt.ts | 42 +++++++++- src/cli/argv.ts | 2 + src/cli/commands/adopt.ts | 2 + src/cli/commands/scan.ts | 24 ++++-- src/cli/commands/watch.ts | 1 + src/core/priority.ts | 44 ++++++++++- src/core/scanResultSchema.ts | 11 +++ src/core/types.ts | 9 +++ src/reporters/markdownReporter.ts | 3 + src/reporters/prCommentReporter.ts | 60 ++++++++------ src/reporters/terminalReporter.ts | 3 + tests/action/actionYml.test.ts | 4 + tests/cli/adopt.test.ts | 26 ++++++ tests/cli/completions.test.ts | 3 + tests/cli/scan.test.ts | 96 +++++++++++++++++++++++ tests/cli/watch.test.ts | 3 + tests/core/priority.test.ts | 37 ++++++++- tests/core/scanResultSchema.test.ts | 3 + tests/reporters/prCommentReporter.test.ts | 68 ++++++++++++++++ 25 files changed, 495 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index cc39b2e..8fb95d0 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,7 @@ Options: --sarif-category SARIF only: set runs[].automationDetails.id --junit-fail-on JUnit only: failed testcase severity threshold --markdown-heatmap [limit] Markdown only: append a debt heatmap table +--top show only the top N payoff-ranked findings; gates use the full scan --pr-comment-max-findings PR comment only: cap detailed findings --pr-comment-max-bytes PR comment only: cap rendered body bytes --pr-comment-full-report-url PR comment only: link omitted findings to a full report @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/action.yml b/action.yml index d84a555..123c993 100644 --- a/action.yml +++ b/action.yml @@ -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. diff --git a/docs/ci-github.md b/docs/ci-github.md index 600b9dd..41ef106 100644 --- a/docs/ci-github.md +++ b/docs/ci-github.md @@ -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. diff --git a/docs/prioritization.md b/docs/prioritization.md index 7fa43db..d2b1195 100644 --- a/docs/prioritization.md +++ b/docs/prioritization.md @@ -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. @@ -37,6 +38,9 @@ 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 @@ -44,6 +48,12 @@ When payoff scores are enabled, JSON output also includes 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 @@ -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). diff --git a/docs/quickstart.md b/docs/quickstart.md index dd5e4af..c21465c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -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 diff --git a/schema/debtlens.scan-result.schema.json b/schema/debtlens.scan-result.schema.json index 45b73c9..afbe884 100644 --- a/schema/debtlens.scan-result.schema.json +++ b/schema/debtlens.scan-result.schema.json @@ -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": { diff --git a/src/cli/adopt.ts b/src/cli/adopt.ts index 490e9a7..f87412d 100644 --- a/src/cli/adopt.ts +++ b/src/cli/adopt.ts @@ -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, @@ -29,6 +30,7 @@ export interface AdoptInput { packageName?: string; writeBaseline?: boolean | string; format?: "terminal" | "markdown"; + topFindings?: number; } export interface AdoptResult { @@ -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) @@ -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) { @@ -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) @@ -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( "", @@ -173,6 +179,9 @@ export async function runAdopt(input: AdoptInput): Promise { : 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); @@ -193,8 +202,8 @@ export async function runAdopt(input: AdoptInput): Promise { } 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; @@ -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"); } @@ -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}`); + } +} diff --git a/src/cli/argv.ts b/src/cli/argv.ts index 08d6a39..dcdf9cf 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -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[] { @@ -83,6 +84,7 @@ export function buildScanArgv(target: string, rawOptions: Record", "show the top N payoff-ranked findings in the adoption report", parseInteger) .option("--format ", "terminal or markdown", "terminal") .action(async (target: string, rawOptions: Record) => { try { @@ -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), diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index 49b5b51..7ba9bf3 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -8,7 +8,7 @@ import { resolveWorkspacePackage } from "../../config/workspaces.js"; import { DEFAULT_BASELINE_FILENAME, createBaseline, writeBaseline } from "../../core/baseline.js"; import { evaluateBudgets, renderBudgetReport, type BudgetEvaluation } from "../../core/budgets.js"; import { buildOwnershipReport, renderOwnershipReportTerminal } from "../../core/ownershipReport.js"; -import { enrichIssuesWithPayoffScores, sortIssuesByPayoff, topPayoffIssues } from "../../core/priority.js"; +import { enrichIssuesWithPayoffScores, selectTopPayoffResult, sortIssuesByPayoff, topPayoffIssues } from "../../core/priority.js"; import { buildGitChurnHotspots } from "../../core/hotspots.js"; import { buildOwnershipSummary, loadCodeowners } from "../../core/ownership.js"; import { scan } from "../../core/scan.js"; @@ -123,6 +123,7 @@ export function registerScanCommand(program: Command): void { .option("--pr-comment-full-report-url ", "with --format pr-comment, link omitted findings to a full report artifact") .option("--budget-report", "print per-area budget usage without failing the gate") .option("--sort ", "sort findings by severity or payoff") + .option("--top ", "show only the top N payoff-ranked findings while evaluating the full scan", parseInteger) .action(async (target: string, rawOptions: Record) => { try { const result = await runScanCommand(target, rawOptions); @@ -218,7 +219,11 @@ export async function runScanCommand(target: string, rawOptions: Record): void { if (rawOptions.failOnRegression === true && !rawOptions.baseline && !rawOptions.diffBase) { throw new Error("Use --fail-on-regression with --baseline or --diff-base."); } + if (rawOptions.top !== undefined) { + const format = String(rawOptions.format ?? "terminal"); + if (!["terminal", "markdown", "json", "pr-comment"].includes(format)) { + throw new Error(`Use --top with terminal, markdown, json, or pr-comment output, not ${format}.`); + } + } } function emitScanDiagnostics( @@ -445,12 +456,14 @@ function enrichPayoffScores( rawOptions: Record, fileConfig: DebtLensConfig, ): void { - if (rawOptions.sort === "payoff") { + if (rawOptions.sort === "payoff" || rawOptions.top !== undefined) { enrichIssuesWithPayoffScores(reported.issues, { hotspots: reported.summary.hotspots, weights: fileConfig.priority, }); - reported.issues = sortIssuesByPayoff(reported.issues); + if (rawOptions.sort === "payoff") { + reported.issues = sortIssuesByPayoff(reported.issues); + } } else if (rawOptions.blameAge === true || reported.summary.hotspots) { enrichIssuesWithPayoffScores(reported.issues, { hotspots: reported.summary.hotspots, @@ -458,7 +471,8 @@ function enrichPayoffScores( }); } if (reported.issues.some((issue) => issue.payoffScore !== undefined)) { - reported.summary.topPayoffTargets = topPayoffIssues(reported.issues, 10).map((issue) => ({ + const requestedLimit = typeof rawOptions.top === "number" ? rawOptions.top : 10; + reported.summary.topPayoffTargets = topPayoffIssues(reported.issues, Math.min(requestedLimit, 10)).map((issue) => ({ id: issue.id, fingerprint: issue.fingerprint, ruleId: issue.ruleId, diff --git a/src/cli/commands/watch.ts b/src/cli/commands/watch.ts index aee9698..d1c2ebe 100644 --- a/src/cli/commands/watch.ts +++ b/src/cli/commands/watch.ts @@ -50,6 +50,7 @@ export function registerWatchCommand(program: Command): void { .option("--sarif-category ", "with --format sarif, set runs[].automationDetails.id for separated code scanning runs") .option("--junit-fail-on ", "with --format junit, mark findings at or above this severity as failed testcases") .option("--markdown-heatmap [limit]", "with --format markdown, append a debt heatmap table", parseOptionalInteger) + .option("--top ", "show only the top N payoff-ranked findings while evaluating the full scan", parseInteger) .option("--debounce ", "watch debounce in milliseconds", parseInteger) .action((target: string, rawOptions: Record) => { try { diff --git a/src/core/priority.ts b/src/core/priority.ts index da76003..d6e2653 100644 --- a/src/core/priority.ts +++ b/src/core/priority.ts @@ -1,4 +1,5 @@ -import type { DebtIssue, ScanHotspotSummary, Severity } from "./types.js"; +import { buildDuplicateLogicClusters, buildRuleCorrelations, summarizeIssues } from "./issueAggregates.js"; +import type { DebtIssue, ScanHotspotSummary, ScanResult, Severity } from "./types.js"; const defaultSeverityWeight: Record = { high: 16, @@ -74,6 +75,47 @@ export function topPayoffIssues(issues: T[], limit = 10): T return sortIssuesByPayoff(issues).slice(0, Math.max(0, limit)); } +export function selectTopPayoffResult(result: ScanResult, limit: number): ScanResult { + const selectedIssues = topPayoffIssues(result.issues, limit); + const selectedCounts = summarizeIssues(selectedIssues); + const summary = { + ...result.summary, + ...selectedCounts, + issueSelection: { + strategy: "payoff" as const, + limit, + totalAvailable: result.issues.length, + omitted: Math.max(0, result.issues.length - selectedIssues.length), + }, + topPayoffTargets: topPayoffIssues(selectedIssues, 10).map((issue) => ({ + id: issue.id, + fingerprint: issue.fingerprint, + ruleId: issue.ruleId, + file: issue.file, + severity: issue.severity, + payoffScore: issue.payoffScore ?? 0, + ...(issue.location ? { location: issue.location } : {}), + })), + }; + + const correlations = buildRuleCorrelations(selectedIssues); + const duplicateClusters = buildDuplicateLogicClusters(selectedIssues); + if (correlations.length > 0) summary.correlations = correlations; + else delete summary.correlations; + if (duplicateClusters.length > 0) summary.duplicateClusters = duplicateClusters; + else delete summary.duplicateClusters; + + // These aggregates describe the full issue set and would be misleading beside selected counts. + delete summary.hotspots; + delete summary.ownership; + + return { + ...result, + issues: selectedIssues, + summary, + }; +} + function buildChurnLookup(hotspots?: ScanHotspotSummary): Map | undefined { if (!hotspots?.ranking.length) return undefined; const lookup = new Map(); diff --git a/src/core/scanResultSchema.ts b/src/core/scanResultSchema.ts index 0be5737..cfaa8b2 100644 --- a/src/core/scanResultSchema.ts +++ b/src/core/scanResultSchema.ts @@ -311,6 +311,17 @@ export function buildScanResultSchema(): Record { rulesRun: { type: "integer", minimum: 0 }, elapsedMs: { type: "integer", minimum: 0 }, topPayoffTargets: { type: "array", maxItems: 10, items: payoffTarget }, + 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: { type: "string" } }, filterStats: { type: "object", diff --git a/src/core/types.ts b/src/core/types.ts index 8fd4f52..e8147b8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -47,6 +47,13 @@ export interface PayoffTarget { location?: IssueLocation; } +export interface IssueSelectionSummary { + strategy: "payoff"; + limit: number; + totalAvailable: number; + omitted: number; +} + export interface SourceFileInfo { absolutePath: string; relativePath: string; @@ -553,6 +560,8 @@ export interface ScanSummary { importGraph?: ImportGraph; /** Deterministic, bounded payoff shortlist for machine consumers. */ topPayoffTargets?: PayoffTarget[]; + /** Presentation-only finding selection; gates and baseline writes still evaluate the full result. */ + issueSelection?: IssueSelectionSummary; } export interface ScanResult { diff --git a/src/reporters/markdownReporter.ts b/src/reporters/markdownReporter.ts index 750b3a7..e288e74 100644 --- a/src/reporters/markdownReporter.ts +++ b/src/reporters/markdownReporter.ts @@ -29,6 +29,9 @@ export function renderMarkdown(result: ScanResult, options: MarkdownOptions = {} for (const severity of severityOrder) { lines.push(`- ${capitalize(severity)}: **${result.summary.bySeverity[severity]}**`); } + if (result.summary.issueSelection) { + lines.push(`- Selection: **showing ${result.summary.totalIssues} of ${result.summary.issueSelection.totalAvailable} findings ranked by payoff**; gates and baseline writes use the full scan.`); + } const filterStats = formatFilterStats(result.summary.filterStats); if (filterStats) { lines.push(`- Filtered: **${filterStats}**`); diff --git a/src/reporters/prCommentReporter.ts b/src/reporters/prCommentReporter.ts index 6c1f696..bca762b 100644 --- a/src/reporters/prCommentReporter.ts +++ b/src/reporters/prCommentReporter.ts @@ -1,4 +1,5 @@ import { buildFixTargets, groupIssuesByFile, summarizeIssues } from "../core/issueAggregates.js"; +import { enrichIssuesWithPayoffScores, sortIssuesByPayoff } from "../core/priority.js"; import type { DebtIssue, ScanResult, Severity } from "../core/types.js"; import { formatFilterStats } from "./filterStats.js"; import { escapeHtml } from "./htmlEscape.js"; @@ -23,14 +24,14 @@ export function renderPrComment(result: ScanResult, options: PrCommentOptions = if (options.maxBytes && options.maxBytes > 0) { return renderPrCommentWithinByteLimit(result, options); } - const detailIssues = limitIssuesForComment(result.issues, options.maxFindings); + const detailIssues = limitIssuesForComment(result, options.maxFindings); return renderPrCommentBody(result, options, detailIssues, findingCapReason(options.maxFindings, result.issues.length, detailIssues.length)); } function renderPrCommentWithinByteLimit(result: ScanResult, options: PrCommentOptions): string { const maxFindings = Math.min(result.issues.length, options.maxFindings ?? result.issues.length); for (let count = maxFindings; count >= 0; count -= 1) { - const detailIssues = limitIssuesForComment(result.issues, count); + const detailIssues = limitIssuesForComment(result, count); const report = renderPrCommentBody(result, options, detailIssues, capReasonForByteLimitedRender(options, result.issues.length, maxFindings, count)); if (byteLength(report) <= (options.maxBytes ?? Infinity)) { return report; @@ -52,6 +53,7 @@ function renderPrCommentBody( lines.push("| Files scanned | Rules run | Total issues | High | Medium | Low | Info |"); lines.push("| ---: | ---: | ---: | ---: | ---: | ---: | ---: |"); lines.push(`| ${result.summary.filesScanned} | ${result.summary.rulesRun} | ${result.summary.totalIssues} | ${result.summary.bySeverity.high} | ${result.summary.bySeverity.medium} | ${result.summary.bySeverity.low} | ${result.summary.bySeverity.info} |`); + renderIssueSelection(lines, result); const filterStats = formatFilterStats(result.summary.filterStats); if (filterStats) { lines.push(""); @@ -67,7 +69,11 @@ function renderPrCommentBody( if (result.issues.length === 0) { lines.push(""); - lines.push("No maintainability debt found at the configured severity level."); + if (options.deltaOnly && delta) { + lines.push("No new findings versus the compared baseline."); + } else { + lines.push("No maintainability debt found at the configured severity level."); + } return `${lines.join("\n")}\n`; } @@ -201,6 +207,10 @@ function renderOmittedSummary( lines.push("### Omitted finding summary"); lines.push(""); lines.push(`${omitted.length} finding${omitted.length === 1 ? "" : "s"} omitted from detailed annotations to stay under ${capReason}.`); + if (detailIssues.length > 0) { + const selectionVerb = detailIssues.length === 1 ? "was" : "were"; + lines.push(`The ${detailIssues.length} detailed finding${detailIssues.length === 1 ? "" : "s"} shown ${selectionVerb} selected by payoff score before applying the cap.`); + } lines.push(`Severity: ${formatSeverityCounts(summary.bySeverity)}.`); if (topRules) lines.push(`Top rules: ${topRules}.`); if (topFiles) lines.push(`Top files: ${topFiles}.`); @@ -217,15 +227,25 @@ function renderMinimalPrComment(result: ScanResult, options: PrCommentOptions, o "## DebtLens findings", "", `Issues: ${result.summary.totalIssues} | high ${result.summary.bySeverity.high} | medium ${result.summary.bySeverity.medium} | low ${result.summary.bySeverity.low} | info ${result.summary.bySeverity.info}`, + ]; + renderIssueSelection(lines, result); + lines.push( "", `Detailed annotations are omitted from this comment to stay under ${omissionReason}.`, options.artifactLink ? `Full details: ${options.artifactLink}.` : "Full details remain available in the canonical JSON or Markdown artifact.", - ]; + ); const report = `${lines.join("\n")}\n`; if (!options.maxBytes || byteLength(report) <= options.maxBytes) return report; return truncateToByteLimit(report, options.maxBytes); } +function renderIssueSelection(lines: string[], result: ScanResult): void { + const selection = result.summary.issueSelection; + if (!selection) return; + lines.push(""); + lines.push(`Showing ${result.summary.totalIssues} of ${selection.totalAvailable} findings ranked by payoff; gates and baseline writes use the full scan.`); +} + function renderSuppressionAudit(lines: string[], result: ScanResult): void { const directives = result.suppressionDirectives ?? []; if (directives.length === 0) return; @@ -257,9 +277,20 @@ function renderLocation(issue: DebtIssue, sourceUrlBase: string | undefined): st return `[\`${label}\`](${sourceUrlBase}/${encodePath(issue.file)}#L${line})`; } -function limitIssuesForComment(issues: DebtIssue[], maxFindings: number | undefined): DebtIssue[] { +function limitIssuesForComment(result: ScanResult, maxFindings: number | undefined): DebtIssue[] { + const issues = result.issues; if (maxFindings === undefined || maxFindings >= issues.length) return [...issues]; - return [...issues].sort(compareIssuesForComment).slice(0, Math.max(0, maxFindings)); + return rankIssuesForComment(result).slice(0, Math.max(0, maxFindings)); +} + +function rankIssuesForComment(result: ScanResult): DebtIssue[] { + if (result.issues.every((issue) => issue.payoffScore !== undefined)) { + return sortIssuesByPayoff(result.issues); + } + const scoredCopies = result.issues.map((issue) => ({ ...issue })); + enrichIssuesWithPayoffScores(scoredCopies, { hotspots: result.summary.hotspots }); + const originals = new Map(scoredCopies.map((copy, index) => [copy, result.issues[index]])); + return sortIssuesByPayoff(scoredCopies).map((copy) => originals.get(copy)!); } function capReasonForByteLimitedRender( @@ -283,16 +314,6 @@ function byteCapReason(maxBytes: number | undefined): string { return maxBytes ? `the configured ${maxBytes}-byte comment cap` : "the configured comment byte cap"; } -function compareIssuesForComment(left: DebtIssue, right: DebtIssue): number { - const severityDelta = severityRank(right.severity) - severityRank(left.severity); - if (severityDelta !== 0) return severityDelta; - const confidenceDelta = right.confidence - left.confidence; - if (confidenceDelta !== 0) return confidenceDelta; - const fileDelta = left.file.localeCompare(right.file); - if (fileDelta !== 0) return fileDelta; - return (left.location?.startLine ?? 0) - (right.location?.startLine ?? 0); -} - function omittedIssues(allIssues: DebtIssue[], detailIssues: DebtIssue[]): DebtIssue[] { const detailOccurrences = new Set(detailIssues); return allIssues.filter((issue) => !detailOccurrences.has(issue)); @@ -302,13 +323,6 @@ function formatSeverityCounts(bySeverity: Record): string { return `high ${bySeverity.high}, medium ${bySeverity.medium}, low ${bySeverity.low}, info ${bySeverity.info}`; } -function severityRank(severity: Severity): number { - if (severity === "high") return 4; - if (severity === "medium") return 3; - if (severity === "low") return 2; - return 1; -} - function byteLength(value: string): number { return new TextEncoder().encode(value).length; } diff --git a/src/reporters/terminalReporter.ts b/src/reporters/terminalReporter.ts index 7bb7096..818f643 100644 --- a/src/reporters/terminalReporter.ts +++ b/src/reporters/terminalReporter.ts @@ -22,6 +22,9 @@ export function renderTerminal( lines.push(color.bold("DebtLens Report")); lines.push(`Scanned ${result.summary.filesScanned} files with ${result.summary.rulesRun} rules in ${result.summary.elapsedMs}ms.`); lines.push(`Issues: ${result.summary.totalIssues} | high ${result.summary.bySeverity.high} | medium ${result.summary.bySeverity.medium} | low ${result.summary.bySeverity.low} | info ${result.summary.bySeverity.info}`); + if (result.summary.issueSelection) { + lines.push(`Showing ${result.summary.totalIssues} of ${result.summary.issueSelection.totalAvailable} findings ranked by payoff; gates and baseline writes use the full scan.`); + } const filterStats = formatFilterStats(result.summary.filterStats); if (filterStats) { lines.push(`Filtered: ${filterStats}`); diff --git a/tests/action/actionYml.test.ts b/tests/action/actionYml.test.ts index 86c0001..5299e88 100644 --- a/tests/action/actionYml.test.ts +++ b/tests/action/actionYml.test.ts @@ -131,6 +131,10 @@ describe("GitHub Action metadata", () => { assert.match(actionYml, /DEBTLENS_COMMENT_FAIL_ON_ERROR="\$DL_COMMENT_FAIL_ON_ERROR"/); }); + it("does not reorder the canonical Action JSON when PR comments are enabled", () => { + assert.doesNotMatch(actionYml, /\[ "\$DL_COMMENT" = "true" \] && args\+=\(--sort payoff\)/); + }); + it("exposes scan metrics, gate status, and artifact paths as Action outputs", () => { for (const output of [ "scan-status", diff --git a/tests/cli/adopt.test.ts b/tests/cli/adopt.test.ts index f63c5d0..b305817 100644 --- a/tests/cli/adopt.test.ts +++ b/tests/cli/adopt.test.ts @@ -164,6 +164,32 @@ describe("debtlens adopt", () => { assert.match(result.stdout, /Rationale: .*Recommended first pack: core/); }); + it("shows a stable top-N payoff shortlist without hiding the full adoption totals", () => { + writeFileSync(join(dir, "src", "second.ts"), [ + "export function crowded(a: boolean, b: boolean, c: boolean, d: boolean, e: boolean, f: boolean) {", + " return a || b || c || d || e || f;", + "}", + "", + ].join("\n")); + + const result = runAdopt([ + ".", + "--cwd", + dir, + "--rules", + "todo-comment,long-parameter-list", + "--top", + "1", + ]); + + assert.equal(result.status, 0); + assert.match(result.stdout, /Total issues: 2/); + assert.match(result.stdout, /Highest-signal findings \(1 of 2\):/); + assert.match(result.stdout, /\[high\] Long parameter list/); + assert.match(result.stdout, /debtlens adopt .*--top 1 .*--gate advisory/); + assert.doesNotMatch(result.stdout, /debtlens scan .*--top 1/); + }); + it("prints threshold suggestions when adoption findings exceed defaults", () => { const result = runAdopt(["examples/react", "--rules", "large-component", "--threshold", "large-component.maxLines=20"]); diff --git a/tests/cli/completions.test.ts b/tests/cli/completions.test.ts index d1b6012..af21fc7 100644 --- a/tests/cli/completions.test.ts +++ b/tests/cli/completions.test.ts @@ -35,6 +35,7 @@ describe("debtlens completions", () => { assert.match(result.stdout, /--churn-range/); assert.match(result.stdout, /--ownership/); assert.match(result.stdout, /--codeowners/); + assert.match(result.stdout, /--top/); assert.match(result.stdout, /--junit-fail-on/); assert.match(result.stdout, /--gate\) COMPREPLY=\( \$\(compgen -W "advisory new-code strict-new-code legacy-baseline"/); assert.match(result.stdout, /prop-drilling/); @@ -65,6 +66,7 @@ describe("debtlens completions", () => { assert.match(zsh.stdout, /'--churn-range' \\/); assert.match(zsh.stdout, /'--ownership' \\/); assert.match(zsh.stdout, /'--codeowners' \\/); + assert.match(zsh.stdout, /'--top' \\/); assert.match(zsh.stdout, /--junit-fail-on\[JUnit failing severity\]:severity:\(info low medium high\)/); assert.match(zsh.stdout, /--gate\[quality gate preset\]:gate:\(advisory new-code strict-new-code legacy-baseline\)/); assert.equal(fish.status, 0); @@ -77,6 +79,7 @@ describe("debtlens completions", () => { assert.match(fish.stdout, /-l churn-range/); assert.match(fish.stdout, /-l ownership/); assert.match(fish.stdout, /-l codeowners/); + assert.match(fish.stdout, /-l top/); assert.match(fish.stdout, /-l junit-fail-on -a "info low medium high"/); assert.match(fish.stdout, /-l gate -a "advisory new-code strict-new-code legacy-baseline"/); assert.match(fish.stdout, /not __fish_seen_subcommand_from baseline compare" -l format -a "terminal json markdown pr-comment sarif html junit gitlab-codequality badge"/); diff --git a/tests/cli/scan.test.ts b/tests/cli/scan.test.ts index 5fc9eee..4634c8b 100644 --- a/tests/cli/scan.test.ts +++ b/tests/cli/scan.test.ts @@ -465,6 +465,102 @@ describe("debtlens scan output formats", () => { assert.equal(terminal.status, 0); assert.match(terminal.stdout, /Top payoff targets/); }); + + it("renders a count-consistent top-N payoff view while retaining the full available count", () => { + const json = runScan([ + "examples/react", + "--rules", + "todo-comment,prop-drilling", + "--top", + "1", + "--format", + "json", + ]); + + assert.equal(json.status, 0); + const parsed = JSON.parse(json.stdout) as { + issues: Array<{ fingerprint: string; ruleId: string; payoffScore?: number }>; + summary: { + totalIssues: number; + issueSelection?: { strategy: string; limit: number; totalAvailable: number; omitted: number }; + }; + }; + assert.equal(parsed.issues.length, 1); + assert.equal(parsed.summary.totalIssues, parsed.issues.length); + assert.deepEqual(parsed.summary.issueSelection, { + strategy: "payoff", + limit: 1, + totalAvailable: 2, + omitted: 1, + }); + assert.equal(parsed.issues[0]?.ruleId, "prop-drilling"); + assert.ok(parsed.issues[0]?.payoffScore); + + const terminal = runScan([ + "examples/react", + "--rules", + "todo-comment,prop-drilling", + "--top", + "1", + ]); + assert.equal(terminal.status, 0); + assert.match(terminal.stdout, /Showing 1 of 2 findings ranked by payoff/); + assert.match(terminal.stdout, /Prop drilling/); + assert.doesNotMatch(terminal.stdout, /TODO comment/); + + const markdown = runScan([ + "examples/react", + "--rules", + "todo-comment,prop-drilling", + "--top", + "1", + "--format", + "markdown", + ]); + assert.equal(markdown.status, 0); + assert.match(markdown.stdout, /showing 1 of 2 findings ranked by payoff/); + assert.match(markdown.stdout, /Prop drilling/); + assert.doesNotMatch(markdown.stdout, /TODO comment/); + + const prComment = runScan([ + "examples/react", + "--rules", + "todo-comment,prop-drilling", + "--top", + "1", + "--format", + "pr-comment", + ]); + assert.equal(prComment.status, 0); + assert.match(prComment.stdout, /Showing 1 of 2 findings ranked by payoff/); + assert.match(prComment.stdout, /gates and baseline writes use the full scan/); + assert.match(prComment.stdout, /Prop drilling/); + assert.doesNotMatch(prComment.stdout, /TODO comment/); + }); + + it("writes the full baseline even when --top limits the report", () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-top-baseline-")); + try { + const baselinePath = join(dir, "baseline.json"); + const result = runScan([ + "examples/react", + "--rules", + "todo-comment,prop-drilling", + "--top", + "1", + "--write-baseline", + baselinePath, + ]); + + assert.equal(result.status, 0); + const baseline = JSON.parse(readFileSync(baselinePath, "utf8")) as { + summary: { totalIssues: number }; + }; + assert.equal(baseline.summary.totalIssues, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("debtlens scan fail-on confidence", () => { diff --git a/tests/cli/watch.test.ts b/tests/cli/watch.test.ts index d92a7e8..e01ef3f 100644 --- a/tests/cli/watch.test.ts +++ b/tests/cli/watch.test.ts @@ -22,6 +22,7 @@ describe("debtlens watch", () => { ownership: true, codeowners: ".github/CODEOWNERS", auditSuppressions: true, + top: 5, debounce: 10, }); @@ -46,6 +47,8 @@ describe("debtlens watch", () => { "--ownership", "--codeowners", ".github/CODEOWNERS", + "--top", + "5", ]); }); diff --git a/tests/core/priority.test.ts b/tests/core/priority.test.ts index dcd01d2..1c0f8ad 100644 --- a/tests/core/priority.test.ts +++ b/tests/core/priority.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { computePayoffScore, sortIssuesByPayoff, topPayoffIssues } from "../../src/core/priority.js"; -import type { DebtIssue } from "../../src/core/types.js"; +import { computePayoffScore, selectTopPayoffResult, sortIssuesByPayoff, topPayoffIssues } from "../../src/core/priority.js"; +import type { DebtIssue, ScanResult } from "../../src/core/types.js"; const baseIssue: DebtIssue = { id: "1", @@ -47,4 +47,37 @@ describe("payoff ranking", () => { assert.equal(topPayoffIssues(issues).length, 10); assert.equal(topPayoffIssues(issues)[0]?.payoffScore, 11); }); + + it("builds a count-consistent presentation result without mutating the full scan", () => { + const issues = [ + { ...baseIssue, id: "low", fingerprint: "low", severity: "low" as const, payoffScore: 2 }, + { ...baseIssue, id: "high", fingerprint: "high", file: "src/b.ts", severity: "high" as const, payoffScore: 12 }, + ]; + const result: ScanResult = { + schemaVersion: 1, + issues, + summary: { + totalIssues: 2, + bySeverity: { info: 0, low: 1, medium: 0, high: 1 }, + byRule: { "todo-comment": 2 }, + filesScanned: 2, + rulesRun: 1, + elapsedMs: 1, + }, + options: { target: ".", include: [], exclude: [], minSeverity: "low", rules: ["todo-comment"] }, + }; + + const selected = selectTopPayoffResult(result, 1); + + assert.equal(result.issues.length, 2); + assert.deepEqual(selected.issues.map((issue) => issue.id), ["high"]); + assert.equal(selected.summary.totalIssues, selected.issues.length); + assert.deepEqual(selected.summary.bySeverity, { info: 0, low: 0, medium: 0, high: 1 }); + assert.deepEqual(selected.summary.issueSelection, { + strategy: "payoff", + limit: 1, + totalAvailable: 2, + omitted: 1, + }); + }); }); diff --git a/tests/core/scanResultSchema.test.ts b/tests/core/scanResultSchema.test.ts index 31641c5..83ee08d 100644 --- a/tests/core/scanResultSchema.test.ts +++ b/tests/core/scanResultSchema.test.ts @@ -24,6 +24,7 @@ describe("ScanResult JSON schema", () => { correlations?: { items: { required: string[] } }; duplicateClusters?: { items: { required: string[] } }; topPayoffTargets?: { maxItems: number; items: { required: string[] } }; + issueSelection?: { required: string[] }; importGraph?: { required: string[]; properties: { edges: { items: { required: string[] } } } }; hotspots?: { required: string[]; @@ -56,6 +57,8 @@ describe("ScanResult JSON schema", () => { assert.ok(schema.properties.summary.properties.duplicateClusters?.items.required.includes("locations")); assert.equal(schema.properties.summary.properties.topPayoffTargets?.maxItems, 10); assert.ok(schema.properties.summary.properties.topPayoffTargets?.items.required.includes("payoffScore")); + assert.ok(schema.properties.summary.properties.issueSelection?.required.includes("totalAvailable")); + assert.ok(schema.properties.summary.properties.issueSelection?.required.includes("omitted")); assert.ok(schema.properties.summary.properties.importGraph?.required.includes("edges")); assert.ok(schema.properties.summary.properties.importGraph?.properties.edges.items.required.includes("inCycle")); assert.ok(schema.properties.summary.properties.hotspots?.required.includes("ranking")); diff --git a/tests/reporters/prCommentReporter.test.ts b/tests/reporters/prCommentReporter.test.ts index 54662b9..c475972 100644 --- a/tests/reporters/prCommentReporter.test.ts +++ b/tests/reporters/prCommentReporter.test.ts @@ -81,6 +81,21 @@ describe("pr-comment reporter", () => { assert.match(markdown, /
src\/release plan\.ts<\/code> - 1 finding<\/summary>/); }); + it("discloses a payoff-selected report view and its full-scan gate semantics", () => { + const result = makeResult([{ ...propIssue, payoffScore: 12 }]); + result.summary.issueSelection = { + strategy: "payoff", + limit: 1, + totalAvailable: 4, + omitted: 3, + }; + + const markdown = renderPrComment(result); + + assert.match(markdown, /Showing 1 of 4 findings ranked by payoff/); + assert.match(markdown, /gates and baseline writes use the full scan/); + }); + it("renders baseline delta copy in delta-only mode", () => { const result = makeResult([propIssue]); result.summary.deltaFromBaseline = { @@ -102,6 +117,28 @@ describe("pr-comment reporter", () => { assert.doesNotMatch(markdown, /remain available in the JSON report/); }); + it("renders a clean empty delta state", () => { + const result = makeResult([]); + result.summary.deltaFromBaseline = { + new: 0, + resolved: 2, + changed: 0, + severityRegressions: 0, + totalDelta: -2, + baseline: { totalIssues: 2, bySeverity: { info: 0, low: 0, medium: 0, high: 2 }, byRule: { "prop-drilling": 2 } }, + current: { totalIssues: 0, bySeverity: { info: 0, low: 0, medium: 0, high: 0 }, byRule: {} }, + hasBaselineSummary: true, + byRule: { "prop-drilling": { baseline: 2, current: 0, delta: -2 } }, + }; + + const markdown = renderPrComment(result, { deltaOnly: true, maxFindings: 20 }); + + assert.match(markdown, /Delta: -2 total, 0 new, 2 resolved, 0 changed/); + assert.match(markdown, /No new findings versus the compared baseline\./); + assert.doesNotMatch(markdown, /No maintainability debt found/); + assert.doesNotMatch(markdown, /Omitted finding summary|Grouped annotations/); + }); + it("normalizes multi-line finding text for PR comments", () => { const markdown = renderPrComment(makeResult([{ ...propIssue, @@ -237,6 +274,22 @@ describe("pr-comment reporter", () => { assert.doesNotMatch(markdown, /Naming drift \(`naming-drift`\) at/); }); + it("selects top-N detailed findings by existing payoff score", () => { + const lowPayoffHighSeverity = { ...propIssue, payoffScore: 3 }; + const highPayoffInfoSeverity = { ...namingIssue, payoffScore: 40 }; + const mediumPayoff = { ...stateIssue, payoffScore: 12 }; + + const markdown = renderPrComment( + makeResult([lowPayoffHighSeverity, mediumPayoff, highPayoffInfoSeverity]), + { maxFindings: 1 }, + ); + + assert.match(markdown, /Naming drift \(`naming-drift`\) at/); + assert.doesNotMatch(markdown, /Prop drilling \(`prop-drilling`\) at/); + assert.doesNotMatch(markdown, /State sprawl \(`state-sprawl`\) at/); + assert.match(markdown, /The 1 detailed finding shown was selected by payoff score before applying the cap\./); + }); + it("attributes omitted findings to the finding cap when a byte cap is also configured", () => { const markdown = renderPrComment(makeResult([propIssue, stateIssue, namingIssue]), { maxFindings: 1, @@ -279,6 +332,21 @@ describe("pr-comment reporter", () => { assert.match(markdown, /configured 2200-byte comment cap/); }); + it("explains payoff selection when the byte cap truncates detailed findings", () => { + const issues = Array.from({ length: 8 }, (_, index) => ({ + ...propIssue, + id: `payoff-long-${index}`, + fingerprint: `payoff-long-${index}`, + file: `src/PayoffLong${index}.tsx`, + message: `Long issue ${index} ${"x".repeat(300)}`, + payoffScore: index + 1, + })); + const markdown = renderPrComment(makeResult(issues), { maxBytes: 2200 }); + + assert.match(markdown, /selected by payoff score before applying the cap/); + assert.match(markdown, /configured 2200-byte comment cap/); + }); + it("falls back to a minimal truncated comment when fixed sections exceed the byte cap", () => { const markdown = renderPrComment(makeResult([propIssue, stateIssue, namingIssue]), { maxBytes: 180,