From 1d4d0a130ac24adf28628dac87ccbd2a332d205f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 01:24:18 +0000 Subject: [PATCH 1/2] feat: add wave 1 detectors, budgets, and badge format (#260, #261, #262, #252, #265) - Add long-parameter-list, god-file, and cognitive-complexity core rules - Add config budgets block with --budget-report and gate failures - Add --format badge with SVG output and shields.io endpoint JSON - Extend schema, docs, and CLI integration tests Co-authored-by: ColumbusLabs --- CHANGELOG.md | 4 + README.md | 3 + docs/example-report.md | 2 +- docs/rules.md | 43 ++++++ schema/debtlens.config.schema.json | 100 +++++++++++++ src/cli/commands/scan.ts | 36 ++++- src/cli/parse.ts | 4 +- src/config/defaults.ts | 9 +- src/config/loadConfig.ts | 2 + src/config/mergeConfig.ts | 1 + src/config/packs.ts | 3 + src/config/schema.ts | 22 +++ src/core/budgets.ts | 149 ++++++++++++++++++++ src/core/types.ts | 19 ++- src/detectors/cognitiveComplexity.ts | 106 ++++++++++++++ src/detectors/godFile.ts | 143 +++++++++++++++++++ src/detectors/index.ts | 6 + src/detectors/longParameterList.ts | 129 +++++++++++++++++ src/reporters/badgeReporter.ts | 116 +++++++++++++++ src/reporters/index.ts | 11 +- tests/cli/plugins.test.ts | 4 +- tests/cli/scan.test.ts | 60 +++++++- tests/config/packs.test.ts | 10 +- tests/core/budgets.test.ts | 59 ++++++++ tests/detectors/cognitiveComplexity.test.ts | 45 ++++++ tests/detectors/godFile.test.ts | 44 ++++++ tests/detectors/longParameterList.test.ts | 49 +++++++ tests/reporters/badgeReporter.test.ts | 75 ++++++++++ 28 files changed, 1239 insertions(+), 15 deletions(-) create mode 100644 src/core/budgets.ts create mode 100644 src/detectors/cognitiveComplexity.ts create mode 100644 src/detectors/godFile.ts create mode 100644 src/detectors/longParameterList.ts create mode 100644 src/reporters/badgeReporter.ts create mode 100644 tests/core/budgets.test.ts create mode 100644 tests/detectors/cognitiveComplexity.test.ts create mode 100644 tests/detectors/godFile.test.ts create mode 100644 tests/detectors/longParameterList.test.ts create mode 100644 tests/reporters/badgeReporter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d53c98..fd9a16c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to DebtLens are documented here. This project adheres to ### Added +- 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. diff --git a/README.md b/README.md index b0cef4e..996d38a 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/docs/example-report.md b/docs/example-report.md index 87ad59a..5a7a3b5 100644 --- a/docs/example-report.md +++ b/docs/example-report.md @@ -1,6 +1,6 @@ # DebtLens Report -Scanned **3** files with **32** rules in **162ms**. +Scanned **3** files with **35** rules in **162ms**. ## Summary diff --git a/docs/rules.md b/docs/rules.md index 7f046f5..a1827b7 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -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. diff --git a/schema/debtlens.config.schema.json b/schema/debtlens.config.schema.json index ba78aa8..63e770a 100644 --- a/schema/debtlens.config.schema.json +++ b/schema/debtlens.config.schema.json @@ -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", @@ -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" }, @@ -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", @@ -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, @@ -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 + } + } } } } diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index 88a6684..32cafc6 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -6,6 +6,7 @@ import { mergeConfig } from "../../config/mergeConfig.js"; import { RULE_PACK_IDS } from "../../config/packs.js"; import { resolveWorkspacePackage } from "../../config/workspaces.js"; import { DEFAULT_BASELINE_FILENAME, createBaseline, writeBaseline } from "../../core/baseline.js"; +import { evaluateBudgets, renderBudgetReport } from "../../core/budgets.js"; import { buildGitChurnHotspots } from "../../core/hotspots.js"; import { buildOwnershipSummary, loadCodeowners } from "../../core/ownership.js"; import { scan } from "../../core/scan.js"; @@ -14,6 +15,7 @@ import { parseSeverity } from "../../core/severity.js"; import type { DebtIssue, OutputFormat, ScanOptions, ScanResult } from "../../core/types.js"; import { detectorIds } from "../../detectors/index.js"; import { renderReport } from "../../reporters/index.js"; +import { renderBadgeEndpoint, parseBadgeThresholds } from "../../reporters/badgeReporter.js"; import { applyGatePresetDefaults, gatePresets } from "../../core/gatePresets.js"; import { formatProfileReport, @@ -57,7 +59,7 @@ export function registerScanCommand(program: Command): void { .option("--rules ", `comma-separated rule ids. Available: ${detectorIds.join(", ")}`) .option("--threshold ", "comma-separated key=value threshold overrides") .option("--max-files ", "maximum files to scan", parseInteger) - .option("--format ", "terminal, json, markdown, pr-comment, sarif, html, junit, or gitlab-codequality", "terminal") + .option("--format ", "terminal, json, markdown, pr-comment, sarif, html, junit, gitlab-codequality, or badge", "terminal") .option("-o, --output ", "write the report to a file instead of stdout") .option("--fail-on ", "exit with code 1 when any issue meets this severity") .option("--fail-on-confidence <0-1>", "with --fail-on, require at least this confidence to fail", parseConfidence) @@ -93,6 +95,7 @@ export function registerScanCommand(program: Command): void { .option("--pr-comment-max-findings ", "with --format pr-comment, cap detailed findings and summarize omitted findings", parseNonNegativeInteger) .option("--pr-comment-max-bytes ", "with --format pr-comment, cap the rendered comment body in bytes", parseInteger) .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") .action(async (target: string, rawOptions: Record) => { try { const result = await runScanCommand(target, rawOptions); @@ -241,6 +244,19 @@ export async function runScanCommand(target: string, rawOptions: Record shouldFailOnIssue(issue, failOn, failOnConfidence))) { exitCode = 1; @@ -262,6 +290,12 @@ export async function runScanCommand(target: string, rawOptions: Record> = { +export const defaultConfig: Required> = { include: getLanguageDefinition("tsjs").includeGlobs, exclude: [ "node_modules/**", @@ -90,6 +90,13 @@ export const defaultConfig: Required { enum: [...gatePresets], 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 }, + }, + }, }, }; } diff --git a/src/core/budgets.ts b/src/core/budgets.ts new file mode 100644 index 0000000..7f73f38 --- /dev/null +++ b/src/core/budgets.ts @@ -0,0 +1,149 @@ +import { summarizeIssues } from "./issueAggregates.js"; +import type { DebtIssue, ScanResult, Severity } from "./types.js"; + +export interface AreaBudget { + maxIssues?: number; + maxHigh?: number; + maxMedium?: number; +} + +export type BudgetConfig = Record; + +export interface AreaBudgetUsage { + pattern: string; + issueCount: number; + bySeverity: Record; + maxIssues?: number; + maxHigh?: number; + maxMedium?: number; + headroomIssues?: number; + headroomHigh?: number; + headroomMedium?: number; + breached: boolean; + breachMessages: string[]; +} + +export interface BudgetEvaluation { + areas: AreaBudgetUsage[]; + breached: boolean; + messages: string[]; +} + +export function evaluateBudgets(result: ScanResult, budgets: BudgetConfig | undefined): BudgetEvaluation | undefined { + if (!budgets || Object.keys(budgets).length === 0) return undefined; + + const areas: AreaBudgetUsage[] = []; + const messages: string[] = []; + + for (const [pattern, budget] of Object.entries(budgets)) { + const issues = filterIssuesByPattern(result.issues, pattern); + const summary = summarizeIssues(issues); + const usage: AreaBudgetUsage = { + pattern, + issueCount: summary.totalIssues, + bySeverity: summary.bySeverity, + maxIssues: budget.maxIssues, + maxHigh: budget.maxHigh, + maxMedium: budget.maxMedium, + breached: false, + breachMessages: [], + }; + + if (budget.maxIssues !== undefined) { + usage.headroomIssues = budget.maxIssues - summary.totalIssues; + if (summary.totalIssues > budget.maxIssues) { + usage.breached = true; + usage.breachMessages.push(`${pattern}: ${summary.totalIssues} issues exceeds budget of ${budget.maxIssues}`); + } + } + if (budget.maxHigh !== undefined) { + usage.headroomHigh = budget.maxHigh - summary.bySeverity.high; + if (summary.bySeverity.high > budget.maxHigh) { + usage.breached = true; + usage.breachMessages.push(`${pattern}: ${summary.bySeverity.high} high-severity issues exceeds budget of ${budget.maxHigh}`); + } + } + if (budget.maxMedium !== undefined) { + const mediumPlus = summary.bySeverity.high + summary.bySeverity.medium; + usage.headroomMedium = budget.maxMedium - mediumPlus; + if (mediumPlus > budget.maxMedium) { + usage.breached = true; + usage.breachMessages.push(`${pattern}: ${mediumPlus} medium-or-higher issues exceeds budget of ${budget.maxMedium}`); + } + } + + if (usage.breachMessages.length > 0) { + messages.push(...usage.breachMessages); + } + areas.push(usage); + } + + areas.sort((left, right) => left.pattern.localeCompare(right.pattern)); + return { + areas, + breached: areas.some((area) => area.breached), + messages, + }; +} + +export function renderBudgetReport(evaluation: BudgetEvaluation): string { + const lines = [ + "Area budget report:", + "", + `${"Pattern".padEnd(24)} ${"Used".padEnd(4)} ${"Budget".padEnd(12)} ${"Headroom".padEnd(10)} Status`, + `${"-".repeat(24)} ${"-".repeat(4)} ${"-".repeat(12)} ${"-".repeat(10)} ${"-".repeat(6)}`, + ]; + for (const area of evaluation.areas) { + const budgetParts = [ + area.maxIssues !== undefined ? `issues ${area.issueCount}/${area.maxIssues}` : undefined, + area.maxHigh !== undefined ? `high ${area.bySeverity.high}/${area.maxHigh}` : undefined, + area.maxMedium !== undefined ? `med+ ${area.bySeverity.high + area.bySeverity.medium}/${area.maxMedium}` : undefined, + ].filter(Boolean); + const headroomParts = [ + area.headroomIssues !== undefined ? `issues ${area.headroomIssues}` : undefined, + area.headroomHigh !== undefined ? `high ${area.headroomHigh}` : undefined, + area.headroomMedium !== undefined ? `med+ ${area.headroomMedium}` : undefined, + ].filter(Boolean); + lines.push( + `${area.pattern.padEnd(24)} ${String(area.issueCount).padEnd(4)} ${(budgetParts.join(", ") || "—").padEnd(12)} ${(headroomParts.join(", ") || "—").padEnd(10)} ${area.breached ? "BREACH" : "ok"}`, + ); + } + return `${lines.join("\n")}\n`; +} + +function filterIssuesByPattern(issues: DebtIssue[], pattern: string): DebtIssue[] { + return issues.filter((issue) => pathMatchesPattern(normalizePath(issue.file), pattern)); +} + +function normalizePath(file: string): string { + return file.replaceAll("\\", "/"); +} + +function pathMatchesPattern(path: string, pattern: string): boolean { + const normalizedPattern = pattern.replaceAll("\\", "/"); + if (normalizedPattern.endsWith("/**")) { + const prefix = normalizedPattern.slice(0, -3); + return path === prefix || path.startsWith(`${prefix}/`); + } + if (normalizedPattern.includes("*")) { + let expression = ""; + for (let index = 0; index < normalizedPattern.length; index += 1) { + const char = normalizedPattern[index]; + const next = normalizedPattern[index + 1]; + if (char === "*" && next === "*") { + expression += ".*"; + index += 1; + } else if (char === "*") { + expression += "[^/]*"; + } else { + expression += escapeRegExp(char ?? ""); + } + } + return new RegExp(`^${expression}$`).test(path); + } + return path === normalizedPattern || path.startsWith(`${normalizedPattern}/`); +} + +function escapeRegExp(value: string): string { + return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); +} diff --git a/src/core/types.ts b/src/core/types.ts index 622d418..ee7ad5c 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -1,7 +1,7 @@ import type { Project, SourceFile } from "ts-morph"; export type Severity = "info" | "low" | "medium" | "high"; -export type OutputFormat = "terminal" | "json" | "markdown" | "pr-comment" | "sarif" | "html" | "junit" | "gitlab-codequality"; +export type OutputFormat = "terminal" | "json" | "markdown" | "pr-comment" | "sarif" | "html" | "junit" | "gitlab-codequality" | "badge"; export type TerminalGroupBy = "severity" | "rule" | "file"; export type GatePreset = "advisory" | "new-code" | "strict-new-code" | "legacy-baseline"; @@ -90,6 +90,17 @@ export interface DebtLensConfig { failOnConfidence?: number; /** Named quality-gate rollout preset. Explicit CLI/config gate flags can override its defaults. */ gatePreset?: GatePreset; + /** Per-path debt budgets for area-level SLO gating. */ + budgets?: Record; + /** Badge color thresholds for `--format badge`. */ + badge?: { + greenMax?: number; + yellowMax?: number; + }; /** Rule id -> severity reported for that rule's issues, replacing the detector's choice. */ ruleSeverities?: Record; /** Rule id -> minimum confidence; issues from that rule below the floor are not reported. */ @@ -141,6 +152,8 @@ export interface ScanOptions { ruleSeverities?: Record; /** Rule id -> minimum confidence; issues from that rule below the floor are not reported. */ ruleConfidenceFloors?: Record; + /** Per-path debt budgets loaded from config. */ + budgets?: DebtLensConfig["budgets"]; } export interface CliOptions { @@ -173,6 +186,10 @@ export interface CliOptions { pluginThresholds?: ScanThresholds; /** Naming-drift vocabulary contributed by plugins; user config groups override on id. */ pluginVocabulary?: Record; + /** Per-path debt budgets loaded from config. */ + budgets?: DebtLensConfig["budgets"]; + /** Badge color thresholds. */ + badge?: DebtLensConfig["badge"]; } export interface DetectorContext { diff --git a/src/detectors/cognitiveComplexity.ts b/src/detectors/cognitiveComplexity.ts new file mode 100644 index 0000000..829f480 --- /dev/null +++ b/src/detectors/cognitiveComplexity.ts @@ -0,0 +1,106 @@ +import { Node, SyntaxKind } from "ts-morph"; +import type { Node as MorphNode } from "ts-morph"; +import type { DebtIssue, Detector, DetectorContext } from "../core/types.js"; +import { collectFunctionLikes } from "../utils/ast.js"; +import { createIssue } from "../utils/createIssue.js"; +import { nodeLineSpan } from "../utils/lines.js"; + +export const cognitiveComplexityDetector: Detector = { + id: "cognitive-complexity", + name: "Cognitive complexity", + description: "Flags functions whose nested control flow is hard to read using a Sonar-style cognitive complexity score.", + defaultSeverity: "medium", + tags: ["complexity", "maintainability", "readability"], + detect(context: DetectorContext): DebtIssue[] { + const maxScore = context.getThreshold("cognitive-complexity.max", 15); + const issues: DebtIssue[] = []; + + for (const file of context.files) { + for (const fn of collectFunctionLikes(file)) { + maybePushIssue(issues, fn.name, fn.node, file.relativePath, maxScore); + } + for (const method of file.sourceFile.getDescendantsOfKind(SyntaxKind.MethodDeclaration)) { + const body = method.getBody(); + if (!body) continue; + maybePushIssue(issues, method.getName(), body, file.relativePath, maxScore); + } + } + + return issues; + }, +}; + +function maybePushIssue( + issues: DebtIssue[], + name: string, + body: MorphNode, + file: string, + maxScore: number, +): void { + const score = computeCognitiveComplexity(body); + if (score < maxScore) return; + const span = nodeLineSpan(body); + const overage = score / maxScore; + issues.push(createIssue({ + detector: cognitiveComplexityDetector, + severity: overage >= 1.5 ? "high" : "medium", + confidence: Math.min(0.95, 0.66 + (overage - 1) * 0.24), + file, + location: { startLine: span.startLine, endLine: span.endLine }, + message: `${name} has cognitive complexity ${score}, which is harder to follow than cyclomatic counts alone suggest.`, + evidence: [ + `Cognitive complexity: ${score} / ${maxScore}`, + "Nesting and boolean sequences add extra weight beyond flat branch counts.", + ], + suggestion: "Extract nested branches into named helpers or early-return guard clauses so the main path stays linear.", + })); +} + +function computeCognitiveComplexity(node: MorphNode): number { + return walkNode(node, 0).score; +} + +function walkNode(node: MorphNode, nesting: number): { score: number } { + let score = 0; + + if (isIncrementNode(node)) { + score += 1 + nesting; + } + + const childNesting = isNestingNode(node) ? nesting + 1 : nesting; + for (const child of node.getChildren()) { + score += walkNode(child, childNesting).score; + } + + if (Node.isBinaryExpression(node)) { + const operator = node.getOperatorToken().getText(); + if (operator === "&&" || operator === "||") { + score += 1; + } + } + + return { score }; +} + +function isIncrementNode(node: MorphNode): boolean { + return Node.isIfStatement(node) + || Node.isForStatement(node) + || Node.isForOfStatement(node) + || Node.isForInStatement(node) + || Node.isWhileStatement(node) + || Node.isDoStatement(node) + || Node.isCatchClause(node) + || Node.isConditionalExpression(node) + || (Node.isCaseClause(node) && node.getExpression() !== undefined); +} + +function isNestingNode(node: MorphNode): boolean { + return Node.isIfStatement(node) + || Node.isForStatement(node) + || Node.isForOfStatement(node) + || Node.isForInStatement(node) + || Node.isWhileStatement(node) + || Node.isDoStatement(node) + || Node.isCatchClause(node) + || Node.isSwitchStatement(node); +} diff --git a/src/detectors/godFile.ts b/src/detectors/godFile.ts new file mode 100644 index 0000000..532aa1f --- /dev/null +++ b/src/detectors/godFile.ts @@ -0,0 +1,143 @@ +import { Node, SyntaxKind } from "ts-morph"; +import type { SourceFile } from "ts-morph"; +import type { DebtIssue, Detector, DetectorContext, SourceFileInfo } from "../core/types.js"; +import { collectFunctionLikes } from "../utils/ast.js"; +import { createIssue } from "../utils/createIssue.js"; +import { countLines } from "../utils/lines.js"; + +interface ModuleMetrics { + totalLines: number; + exportCount: number; + topLevelDeclCount: number; + concernCategories: number; +} + +export const godFileDetector: Detector = { + id: "god-file", + name: "God file", + description: "Flags kitchen-sink modules that exceed multiple size, export, and responsibility-spread thresholds together.", + defaultSeverity: "medium", + tags: ["module-boundaries", "maintainability", "architecture"], + detect(context: DetectorContext): DebtIssue[] { + const maxLines = context.getThreshold("god-file.maxLines", 400); + const maxExports = context.getThreshold("god-file.maxExports", 10); + const maxTopLevelDecls = context.getThreshold("god-file.maxTopLevelDecls", 12); + const minAxes = context.getThreshold("god-file.minAxes", 3); + const issues: DebtIssue[] = []; + + for (const file of context.files) { + const metrics = collectModuleMetrics(file); + const axes = countExceededAxes(metrics, maxLines, maxExports, maxTopLevelDecls); + if (axes.length < minAxes) continue; + + const confidence = Math.min(0.95, 0.58 + axes.length * 0.1 + (axes.length >= minAxes + 1 ? 0.08 : 0)); + issues.push(createIssue({ + detector: godFileDetector, + severity: axes.length >= minAxes + 1 ? "high" : "medium", + confidence, + file: file.relativePath, + location: { startLine: 1, endLine: metrics.totalLines }, + message: `${file.relativePath} looks like a kitchen-sink module with ${axes.length} independent sprawl signals.`, + evidence: [ + `Lines: ${metrics.totalLines} / ${maxLines}`, + `Exports: ${metrics.exportCount} / ${maxExports}`, + `Top-level declarations: ${metrics.topLevelDeclCount} / ${maxTopLevelDecls}`, + `Concern categories: ${metrics.concernCategories}`, + `Exceeded axes: ${axes.join(", ")}`, + ], + suggestion: "Split the module by responsibility, extract cohesive submodules, and keep a narrow public entrypoint.", + })); + } + + return issues; + }, +}; + +function collectModuleMetrics(file: SourceFileInfo): ModuleMetrics { + const fileNode = file.sourceFile; + const exportCount = countPublicExports(fileNode); + const topLevelDeclCount = countTopLevelDeclarations(fileNode); + const concernCategories = countConcernCategories(fileNode, collectFunctionLikes(file)); + return { + totalLines: countLines(file.content), + exportCount, + topLevelDeclCount, + concernCategories, + }; +} + +function countExceededAxes( + metrics: ModuleMetrics, + maxLines: number, + maxExports: number, + maxTopLevelDecls: number, +): string[] { + const axes: string[] = []; + if (metrics.totalLines >= maxLines) axes.push("lines"); + if (metrics.exportCount >= maxExports) axes.push("exports"); + if (metrics.topLevelDeclCount >= maxTopLevelDecls) axes.push("top-level declarations"); + if (metrics.concernCategories >= 3) axes.push("mixed concerns"); + return axes; +} + +function countPublicExports(sourceFile: SourceFile): number { + let count = 0; + for (const statement of sourceFile.getStatements()) { + if (hasExportModifier(statement)) count += 1; + if (Node.isExportDeclaration(statement)) { + const named = statement.getNamedExports(); + count += named.length > 0 ? named.length : 1; + } + } + return count; +} + +function hasExportModifier(node: unknown): boolean { + if (!node || typeof node !== "object" || !("getModifiers" in node)) return false; + const modifiers = (node as { getModifiers: () => Array<{ getKind: () => SyntaxKind }> }).getModifiers(); + return modifiers.some((modifier) => + modifier.getKind() === SyntaxKind.ExportKeyword || modifier.getKind() === SyntaxKind.DefaultKeyword, + ); +} + +function countTopLevelDeclarations(sourceFile: SourceFile): number { + let count = 0; + for (const statement of sourceFile.getStatements()) { + if (Node.isFunctionDeclaration(statement) || Node.isClassDeclaration(statement) || Node.isInterfaceDeclaration(statement)) { + count += 1; + } else if (Node.isVariableStatement(statement)) { + count += statement.getDeclarations().length; + } + } + return count; +} + +function countConcernCategories(sourceFile: SourceFile, functions: ReturnType): number { + const categories = new Set(); + for (const importDecl of sourceFile.getImportDeclarations()) { + const specifier = importDecl.getModuleSpecifierValue() ?? ""; + categories.add(categorizeImport(specifier)); + } + for (const fn of functions) { + categories.add(categorizeFunctionName(fn.name)); + } + categories.delete("generic"); + return categories.size; +} + +function categorizeImport(specifier: string): string { + const lower = specifier.toLowerCase(); + if (lower.includes("react") || lower.includes("vue") || lower.includes("svelte")) return "ui"; + if (lower.includes("fs") || lower.includes("path") || lower.includes("http") || lower.includes("express")) return "io"; + if (lower.includes("test") || lower.includes("vitest") || lower.includes("jest")) return "test"; + if (lower.startsWith(".") || lower.startsWith("@/")) return "local"; + return "generic"; +} + +function categorizeFunctionName(name: string): string { + const lower = name.toLowerCase(); + if (lower.includes("render") || lower.startsWith("use") || lower.endsWith("component")) return "ui"; + if (lower.includes("fetch") || lower.includes("load") || lower.includes("save") || lower.includes("write")) return "io"; + if (lower.includes("validate") || lower.includes("compute") || lower.includes("build")) return "domain"; + return "generic"; +} diff --git a/src/detectors/index.ts b/src/detectors/index.ts index 65e28c1..e677f34 100644 --- a/src/detectors/index.ts +++ b/src/detectors/index.ts @@ -4,6 +4,7 @@ import { composeLargeComposableDetector, composeStateHoistingDetector } from "./ import { apiSurfaceSprawlDetector } from "./apiSurfaceSprawl.js"; import { barrelFileDetector } from "./barrelFile.js"; import { contextProviderSprawlDetector } from "./contextProviderSprawl.js"; +import { cognitiveComplexityDetector } from "./cognitiveComplexity.js"; import { complexControlFlowDetector } from "./complexControlFlow.js"; import { configDriftDetector } from "./configDrift.js"; import { dataLoaderSprawlDetector } from "./dataLoaderSprawl.js"; @@ -14,6 +15,7 @@ import { floatingPromiseDetector } from "./floatingPromise.js"; import { duplicateLogicDetector } from "./duplicateLogic.js"; import { duplicatedLiteralDetector } from "./duplicatedLiteral.js"; import { effectComplexityDetector } from "./effectComplexity.js"; +import { godFileDetector } from "./godFile.js"; import { handlerDepthDetector } from "./handlerDepth.js"; import { hookDependencySmellDetector } from "./hookDependencySmell.js"; import { importCycleDetector } from "./importCycle.js"; @@ -22,6 +24,7 @@ import { swiftDeadAbstractionDetector, swiftDuplicateLogicDetector, swiftLargeFu import { swiftuiLargeViewDetector, swiftuiStateSprawlDetector } from "./swiftui/index.js"; import { largeComponentDetector } from "./largeComponent.js"; import { largeFunctionDetector } from "./largeFunction.js"; +import { longParameterListDetector } from "./longParameterList.js"; import { namingDriftDetector } from "./namingDrift.js"; import { propDrillingDetector } from "./propDrilling.js"; import { @@ -81,6 +84,9 @@ export const allDetectors: Detector[] = [ swallowedErrorDetector, floatingPromiseDetector, commentedOutCodeDetector, + longParameterListDetector, + godFileDetector, + cognitiveComplexityDetector, apiSurfaceSprawlDetector, storyOnlyComponentDetector, pythonTodoCommentDetector, diff --git a/src/detectors/longParameterList.ts b/src/detectors/longParameterList.ts new file mode 100644 index 0000000..22f89d2 --- /dev/null +++ b/src/detectors/longParameterList.ts @@ -0,0 +1,129 @@ +import { Node, SyntaxKind } from "ts-morph"; +import type { ArrowFunction, FunctionDeclaration, FunctionExpression, MethodDeclaration, ParameterDeclaration } from "ts-morph"; +import type { DebtIssue, Detector, DetectorContext } from "../core/types.js"; +import { collectFunctionLikes } from "../utils/ast.js"; +import { createIssue } from "../utils/createIssue.js"; +import { nodeLineSpan } from "../utils/lines.js"; + +const FRAMEWORK_SINGLE_PARAM_NAMES = new Set(["props", "state", "action", "context", "event", "req", "res", "next"]); + +export const longParameterListDetector: Detector = { + id: "long-parameter-list", + name: "Long parameter list", + description: "Flags functions with too many parameters or multiple boolean flag parameters.", + defaultSeverity: "medium", + tags: ["function-design", "maintainability", "api-design"], + detect(context: DetectorContext): DebtIssue[] { + const maxParams = context.getThreshold("long-parameter-list.maxParams", 5); + const maxBooleans = context.getThreshold("long-parameter-list.maxBooleans", 2); + const issues: DebtIssue[] = []; + + for (const file of context.files) { + for (const fn of collectFunctionLikes(file)) { + maybePushIssue(issues, fn.name, fn.node, file.relativePath, maxParams, maxBooleans); + } + + for (const method of file.sourceFile.getDescendantsOfKind(SyntaxKind.MethodDeclaration)) { + maybePushIssue(issues, method.getName(), method, file.relativePath, maxParams, maxBooleans); + } + } + + return issues; + }, +}; + +function maybePushIssue( + issues: DebtIssue[], + name: string, + node: FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration, + file: string, + maxParams: number, + maxBooleans: number, +): void { + const params = getParameters(node); + if (params.length === 0) return; + if (shouldSkipSignature(name, params)) return; + + const booleanCount = countBooleanParams(params); + const overParamBudget = params.length > maxParams; + const overBooleanBudget = booleanCount >= maxBooleans; + if (!overParamBudget && !overBooleanBudget) return; + + const body = Node.isMethodDeclaration(node) ? node.getBody() : getFunctionBodyNode(node); + const span = body ? nodeLineSpan(body) : nodeLineSpan(node); + const confidence = computeConfidence(params.length, maxParams, booleanCount, maxBooleans); + + issues.push(createIssue({ + detector: longParameterListDetector, + severity: overParamBudget && overBooleanBudget ? "high" : "medium", + confidence, + file, + location: { startLine: span.startLine, endLine: span.endLine }, + message: overBooleanBudget && overParamBudget + ? `${name} has ${params.length} parameters including ${booleanCount} boolean flags.` + : overBooleanBudget + ? `${name} has ${booleanCount} boolean parameters that read like a boolean trap.` + : `${name} has ${params.length} parameters.`, + evidence: [ + `Parameters: ${params.length} / ${maxParams}`, + ...(booleanCount > 0 ? [`Boolean parameters: ${booleanCount} / ${maxBooleans}`] : []), + `Signature: ${truncateSignature(node)}`, + ], + suggestion: overBooleanBudget + ? "Replace boolean flags with an options object, named constants, or split the function by behavior." + : "Group related inputs into a focused options object or split the function into smaller helpers.", + })); +} + +function getParameters(node: FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration): ParameterDeclaration[] { + if (Node.isMethodDeclaration(node)) { + return node.getParameters(); + } + return node.getParameters(); +} + +function getFunctionBodyNode(node: FunctionDeclaration | ArrowFunction | FunctionExpression): ReturnType { + return node.getBody(); +} + +function shouldSkipSignature(name: string, params: ParameterDeclaration[]): boolean { + if (params.length === 1) { + const paramName = params[0]?.getName().toLowerCase() ?? ""; + if (FRAMEWORK_SINGLE_PARAM_NAMES.has(paramName)) return true; + if (paramName === "props" || paramName.endsWith("props")) return true; + } + if (params.length === 2) { + const names = params.map((param) => param.getName().toLowerCase()); + if (names[0] === "state" && names[1] === "action") return true; + } + if (name === "render" && params.length <= 2) return true; + return false; +} + +function countBooleanParams(params: ParameterDeclaration[]): number { + let count = 0; + for (const param of params) { + const typeNode = param.getTypeNode(); + if (typeNode?.getText() === "boolean") { + count += 1; + continue; + } + const initializer = param.getInitializer()?.getText(); + if (initializer === "true" || initializer === "false") { + count += 1; + } + } + return count; +} + +function computeConfidence(paramCount: number, maxParams: number, booleanCount: number, maxBooleans: number): number { + const paramRatio = paramCount / Math.max(1, maxParams); + const booleanRatio = booleanCount / Math.max(1, maxBooleans); + const ratio = Math.max(paramRatio, booleanRatio); + return Math.min(0.95, 0.62 + (ratio - 1) * 0.22 + (booleanCount >= maxBooleans ? 0.08 : 0)); +} + +function truncateSignature(node: FunctionDeclaration | ArrowFunction | FunctionExpression | MethodDeclaration): string { + const text = node.getText().split("{")[0]?.trim() ?? node.getText(); + return text.length > 120 ? `${text.slice(0, 117)}...` : text; +} diff --git a/src/reporters/badgeReporter.ts b/src/reporters/badgeReporter.ts new file mode 100644 index 0000000..f7f7649 --- /dev/null +++ b/src/reporters/badgeReporter.ts @@ -0,0 +1,116 @@ +import type { ScanResult, Severity } from "../core/types.js"; + +export interface BadgeThresholds { + greenMax: number; + yellowMax: number; +} + +export interface BadgeRenderOptions { + label?: string; + thresholds?: BadgeThresholds; + trend?: "up" | "down" | "flat"; +} + +const defaultThresholds: BadgeThresholds = { + greenMax: 20, + yellowMax: 100, +}; + +export function renderBadgeSvg(result: ScanResult, options: BadgeRenderOptions = {}): string { + const label = options.label ?? "debt"; + const total = result.summary.totalIssues; + const high = result.summary.bySeverity.high; + const trendArrow = renderTrendArrow(options.trend); + const color = colorForBadge(high, total, options.thresholds ?? defaultThresholds); + const message = high > 0 ? `${total} (${high} high)` : String(total); + const width = estimateWidth(label, message, trendArrow); + + return ` + + + + + + + + + + + + ${escapeXml(label)} + ${escapeXml(message)}${trendArrow ?? ""} + +`; +} + +export function renderBadgeEndpoint(result: ScanResult, options: BadgeRenderOptions = {}): string { + const total = result.summary.totalIssues; + const high = result.summary.bySeverity.high; + const color = shieldsColor(colorForBadge(high, total, options.thresholds ?? defaultThresholds)); + const payload = { + schemaVersion: 1, + label: options.label ?? "debt", + message: high > 0 ? `${total} (${high} high)` : String(total), + color, + }; + return `${JSON.stringify(payload, null, 2)}\n`; +} + +function colorForBadge(high: number, total: number, thresholds: BadgeThresholds): string { + if (high > 0) return "#e05d44"; + if (total === 0) return "#4c1"; + return colorForCount(total, thresholds); +} + +function colorForCount(count: number, thresholds: BadgeThresholds): string { + if (count <= thresholds.greenMax) return "#4c1"; + if (count <= thresholds.yellowMax) return "#dfb317"; + return "#e05d44"; +} + +function shieldsColor(hex: string): string { + if (hex === "#4c1") return "brightgreen"; + if (hex === "#dfb317") return "yellow"; + return "red"; +} + +function labelWidth(label: string): number { + return Math.max(54, label.length * 7 + 14); +} + +function messageWidth(message: string, trendArrow?: string): number { + const extra = trendArrow ? 14 : 0; + return Math.max(34, message.length * 7 + 14 + extra); +} + +function estimateWidth(label: string, message: string, trendArrow?: string): number { + return labelWidth(label) + messageWidth(message, trendArrow); +} + +function renderTrendArrow(trend: BadgeRenderOptions["trend"]): string | undefined { + if (trend === "up") return " ↑"; + if (trend === "down") return " ↓"; + if (trend === "flat") return " →"; + return undefined; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """); +} + +export function parseBadgeThresholds(raw: { greenMax?: number; yellowMax?: number; green?: number; yellow?: number } | undefined): BadgeThresholds | undefined { + if (!raw) return undefined; + const greenMax = raw.greenMax ?? raw.green; + const yellowMax = raw.yellowMax ?? raw.yellow; + if (greenMax === undefined && yellowMax === undefined) return undefined; + return { + greenMax: greenMax ?? defaultThresholds.greenMax, + yellowMax: yellowMax ?? defaultThresholds.yellowMax, + }; +} + +export type { Severity }; diff --git a/src/reporters/index.ts b/src/reporters/index.ts index c75e91b..e130531 100644 --- a/src/reporters/index.ts +++ b/src/reporters/index.ts @@ -1,4 +1,5 @@ import type { OutputFormat, ScanResult, Severity } from "../core/types.js"; +import { renderBadgeEndpoint, renderBadgeSvg } from "./badgeReporter.js"; import { renderGitLabCodeQuality } from "./gitlabCodeQualityReporter.js"; import { renderHtml } from "./htmlReporter.js"; import { renderJson } from "./jsonReporter.js"; @@ -22,6 +23,8 @@ export interface RenderReportOptions { prCommentMaxBytes?: number; prCommentArtifactLink?: string; previousResult?: ScanResult; + badgeThresholds?: { greenMax: number; yellowMax: number }; + badgeTrend?: "up" | "down" | "flat"; } export function renderReport(result: ScanResult, format: OutputFormat, options: RenderReportOptions = {}): string { @@ -40,6 +43,12 @@ export function renderReport(result: ScanResult, format: OutputFormat, options: if (format === "html") return renderHtml(result); if (format === "junit") return renderJunit(result, { failOn: options.junitFailOn }); if (format === "gitlab-codequality") return renderGitLabCodeQuality(result); + if (format === "badge") { + return renderBadgeSvg(result, { + thresholds: options.badgeThresholds, + trend: options.badgeTrend, + }); + } if (format === "terminal") return renderTerminal(result, { color: options.color ?? true, quiet: options.quiet, groupBy: options.groupBy }); - throw new Error(`Invalid format "${format}". Expected terminal, json, markdown, pr-comment, sarif, html, junit, or gitlab-codequality.`); + throw new Error(`Invalid format "${format}". Expected terminal, json, markdown, pr-comment, sarif, html, junit, gitlab-codequality, or badge.`); } diff --git a/tests/cli/plugins.test.ts b/tests/cli/plugins.test.ts index 4d51c41..73e93a1 100644 --- a/tests/cli/plugins.test.ts +++ b/tests/cli/plugins.test.ts @@ -127,7 +127,7 @@ describe("debtlens scan with plugins", () => { const parsed = JSON.parse(result.stdout); assert.equal(result.status, 0); - assert.equal(parsed.summary.rulesRun, 18); + assert.equal(parsed.summary.rulesRun, 21); assert.ok(parsed.issues.some((issue: { ruleId: string }) => issue.ruleId === "no-console")); }); }); @@ -146,7 +146,7 @@ describe("debtlens scan with plugins", () => { const parsed = JSON.parse(result.stdout); assert.equal(result.status, 0); - assert.equal(parsed.summary.rulesRun, 18); + assert.equal(parsed.summary.rulesRun, 21); assert.ok(parsed.issues.some((issue: { ruleId: string; file: string }) => issue.ruleId === "python-marker" && issue.file === "src/service.py")); }); diff --git a/tests/cli/scan.test.ts b/tests/cli/scan.test.ts index 5c56d3d..d441551 100644 --- a/tests/cli/scan.test.ts +++ b/tests/cli/scan.test.ts @@ -146,7 +146,7 @@ describe("debtlens scan output formats", () => { const result = runScan(["examples/react", "--format", "nope"]); assert.equal(result.status, 1); - assert.match(result.stderr, /Expected terminal, json, markdown, pr-comment, sarif, html, junit, or gitlab-codequality/); + assert.match(result.stderr, /Expected terminal, json, markdown, pr-comment, sarif, html, junit, gitlab-codequality, or badge/); }); it("emits GitLab Code Quality JSON from CLI flags", () => { @@ -292,6 +292,64 @@ describe("debtlens scan output formats", () => { assert.match(result.stdout, /configured 0-finding detail cap/); assert.doesNotMatch(result.stdout, /### Grouped annotations/); }); + + it("emits badge SVG and writes shields endpoint JSON with --output", () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-badge-")); + try { + const svgPath = join(dir, "debtlens-badge.svg"); + const result = runScan([ + "examples/react", + "--rules", + "todo-comment", + "--format", + "badge", + "--output", + svgPath, + ]); + + assert.equal(result.status, 0); + const svg = readFileSync(svgPath, "utf8"); + assert.match(svg, /^ { + const dir = mkdtempSync(join(tmpdir(), "debtlens-budget-")); + try { + const configPath = join(dir, "debtlens.config.json"); + writeFileSync(configPath, JSON.stringify({ + rules: ["todo-comment"], + budgets: { "src": { maxIssues: 0 } }, + })); + const result = runScan(["examples/react", "--config", configPath, "--format", "json"]); + assert.equal(result.status, 1); + assert.match(result.stderr, /DebtLens budget breach/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints budget report without failing when --budget-report is set", () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-budget-report-")); + try { + const configPath = join(dir, "debtlens.config.json"); + writeFileSync(configPath, JSON.stringify({ + rules: ["todo-comment"], + budgets: { "src": { maxIssues: 0 } }, + })); + const result = runScan(["examples/react", "--config", configPath, "--budget-report"]); + assert.equal(result.status, 0); + assert.match(result.stdout, /Area budget report/); + assert.match(result.stdout, /BREACH/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); describe("debtlens scan fail-on confidence", () => { diff --git a/tests/config/packs.test.ts b/tests/config/packs.test.ts index a51ea18..b4b0a7e 100644 --- a/tests/config/packs.test.ts +++ b/tests/config/packs.test.ts @@ -7,17 +7,17 @@ describe("rule packs", () => { it("lists built-in packs with expected rule counts", () => { const packs = listRulePacks(); assert.equal(packs.length, 19); - assert.equal(getRulePack("core").rules.length, 17); + assert.equal(getRulePack("core").rules.length, 20); assert.deepEqual(getRulePack("core").languages, ["tsjs"]); - assert.equal(getRulePack("react").rules.length, 24); - assert.equal(getRulePack("react-native").rules.length, 25); + assert.equal(getRulePack("react").rules.length, 27); + assert.equal(getRulePack("react-native").rules.length, 28); assert.ok(getRulePack("react-native").rules.includes("rn-host-forwarding")); - assert.equal(getRulePack("next").rules.length, 27); + assert.equal(getRulePack("next").rules.length, 30); assert.ok(getRulePack("next").rules.includes("server-client-boundary")); assert.ok(getRulePack("next").rules.includes("route-handler-size")); assert.ok(getRulePack("next").rules.includes("data-loader-sprawl")); assert.deepEqual(getRulePack("next").duplicatedLiteral?.ignoreStrings, ["use client", "use server"]); - assert.equal(getRulePack("expo").rules.length, 25); + assert.equal(getRulePack("expo").rules.length, 28); assert.ok(getRulePack("node").rules.includes("handler-depth")); assert.ok(getRulePack("node").rules.includes("route-sprawl")); assert.deepEqual(getRulePack("python").rules, [ diff --git a/tests/core/budgets.test.ts b/tests/core/budgets.test.ts new file mode 100644 index 0000000..1e50009 --- /dev/null +++ b/tests/core/budgets.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { evaluateBudgets, renderBudgetReport } from "../../src/core/budgets.js"; +import type { ScanResult } from "../../src/core/types.js"; + +function makeResult(issues: ScanResult["issues"]): ScanResult { + return { + schemaVersion: 1, + issues, + summary: { + totalIssues: issues.length, + bySeverity: { + high: issues.filter((issue) => issue.severity === "high").length, + medium: issues.filter((issue) => issue.severity === "medium").length, + low: issues.filter((issue) => issue.severity === "low").length, + info: issues.filter((issue) => issue.severity === "info").length, + }, + byRule: {}, + filesScanned: 1, + rulesRun: 1, + elapsedMs: 1, + }, + options: { target: ".", include: [], exclude: [], minSeverity: "low" }, + }; +} + +describe("budget evaluation", () => { + it("detects per-area breaches", () => { + const result = makeResult([ + { id: "1", ruleId: "todo-comment", ruleName: "Todo", severity: "high", confidence: 1, file: "src/payments/a.ts", message: "todo", tags: [] }, + { id: "2", ruleId: "todo-comment", ruleName: "Todo", severity: "low", confidence: 1, file: "src/other/b.ts", message: "todo", tags: [] }, + ]); + const evaluation = evaluateBudgets(result, { + "src/payments": { maxIssues: 0, maxHigh: 0 }, + }); + assert.ok(evaluation?.breached); + assert.match(evaluation?.messages.join("\n") ?? "", /src\/payments/); + }); + + it("matches nested paths under a glob prefix", () => { + const result = makeResult([ + { id: "1", ruleId: "todo-comment", ruleName: "Todo", severity: "low", confidence: 1, file: "src/payments/nested/a.ts", message: "todo", tags: [] }, + ]); + const evaluation = evaluateBudgets(result, { + "src/payments/**": { maxIssues: 0 }, + }); + assert.ok(evaluation?.breached); + }); + + it("renders a budget report table", () => { + const result = makeResult([]); + const evaluation = evaluateBudgets(result, { + "src/payments": { maxIssues: 10 }, + }); + const report = renderBudgetReport(evaluation!); + assert.match(report, /Area budget report/); + assert.match(report, /src\/payments/); + }); +}); diff --git a/tests/detectors/cognitiveComplexity.test.ts b/tests/detectors/cognitiveComplexity.test.ts new file mode 100644 index 0000000..97b98cc --- /dev/null +++ b/tests/detectors/cognitiveComplexity.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { cognitiveComplexityDetector } from "../../src/detectors/cognitiveComplexity.js"; +import { runDetector } from "../helpers/runDetector.js"; + +describe("cognitive-complexity detector", () => { + it("flags deeply nested control flow", async () => { + const src = ` +export function review(input: { a?: boolean; b?: boolean; c?: boolean; d?: boolean }) { + if (input.a) { + if (input.b) { + if (input.c) { + if (input.d) { + return "nested"; + } + } + } + } + return "ok"; +} +`; + const issues = await runDetector(cognitiveComplexityDetector, { "review.ts": src }, { + thresholds: { "cognitive-complexity.max": 5 }, + }); + assert.equal(issues.length, 1); + assert.equal(issues[0]?.ruleId, "cognitive-complexity"); + }); + + it("does not flag a flat switch with low nesting", async () => { + const src = ` +export function status(code: number) { + switch (code) { + case 200: return "ok"; + case 404: return "missing"; + case 500: return "error"; + default: return "unknown"; + } +} +`; + const issues = await runDetector(cognitiveComplexityDetector, { "status.ts": src }, { + thresholds: { "cognitive-complexity.max": 15 }, + }); + assert.equal(issues.length, 0); + }); +}); diff --git a/tests/detectors/godFile.test.ts b/tests/detectors/godFile.test.ts new file mode 100644 index 0000000..f6901ee --- /dev/null +++ b/tests/detectors/godFile.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { godFileDetector } from "../../src/detectors/godFile.js"; +import { runDetector } from "../helpers/runDetector.js"; + +describe("god-file detector", () => { + it("flags a kitchen-sink module with multiple sprawl axes", async () => { + const exports = Array.from({ length: 12 }, (_, index) => `export function fn${index}() { return ${index}; }`).join("\n"); + const imports = ` +import fs from "node:fs"; +import React from "react"; +import express from "express"; +`; + const src = `${imports}\n${exports}\n${"// filler\n".repeat(420)}`; + const issues = await runDetector(godFileDetector, { "kitchen.ts": src }, { + thresholds: { + "god-file.maxLines": 200, + "god-file.maxExports": 8, + "god-file.maxTopLevelDecls": 8, + "god-file.minAxes": 3, + }, + }); + assert.equal(issues.length, 1); + assert.equal(issues[0]?.ruleId, "god-file"); + assert.ok((issues[0]?.confidence ?? 0) >= 0.7); + }); + + it("does not flag a large but cohesive single-purpose module", async () => { + const helpers = Array.from({ length: 8 }, (_, index) => ` +export function normalizeField${index}(value: string) { + return value.trim().toLowerCase(); +}`).join("\n"); + const src = `${helpers}\n${"// keep helpers together\n".repeat(40)}`; + const issues = await runDetector(godFileDetector, { "normalize.ts": src }, { + thresholds: { + "god-file.maxLines": 500, + "god-file.maxExports": 20, + "god-file.maxTopLevelDecls": 20, + "god-file.minAxes": 4, + }, + }); + assert.equal(issues.length, 0); + }); +}); diff --git a/tests/detectors/longParameterList.test.ts b/tests/detectors/longParameterList.test.ts new file mode 100644 index 0000000..a83011d --- /dev/null +++ b/tests/detectors/longParameterList.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { longParameterListDetector } from "../../src/detectors/longParameterList.js"; +import { runDetector } from "../helpers/runDetector.js"; + +describe("long-parameter-list detector", () => { + it("flags functions with too many parameters", async () => { + const src = ` +export function build(a: string, b: string, c: string, d: string, e: string, f: string) { + return a + b + c + d + e + f; +} +`; + const issues = await runDetector(longParameterListDetector, { "build.ts": src }); + assert.equal(issues.length, 1); + assert.equal(issues[0]?.ruleId, "long-parameter-list"); + }); + + it("raises confidence for boolean traps", async () => { + const src = ` +export function render(enabled: boolean, visible: boolean, compact: boolean) { + return enabled && visible && compact; +} +`; + const issues = await runDetector(longParameterListDetector, { "render.ts": src }); + assert.equal(issues.length, 1); + assert.ok((issues[0]?.confidence ?? 0) >= 0.7); + assert.match(issues[0]?.message ?? "", /boolean/i); + }); + + it("does not flag React props signature", async () => { + const src = ` +export function Dashboard(props: { userId: string; region: string; theme: string }) { + return props.userId; +} +`; + const issues = await runDetector(longParameterListDetector, { "Dashboard.tsx": src }); + assert.equal(issues.length, 0); + }); + + it("does not flag reducer state/action signature", async () => { + const src = ` +export function reducer(state: { count: number }, action: { type: string }) { + return state; +} +`; + const issues = await runDetector(longParameterListDetector, { "reducer.ts": src }); + assert.equal(issues.length, 0); + }); +}); diff --git a/tests/reporters/badgeReporter.test.ts b/tests/reporters/badgeReporter.test.ts new file mode 100644 index 0000000..2e769d8 --- /dev/null +++ b/tests/reporters/badgeReporter.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderBadgeEndpoint, renderBadgeSvg } from "../../src/reporters/badgeReporter.js"; +import type { ScanResult } from "../../src/core/types.js"; + +const sampleResult: ScanResult = { + schemaVersion: 1, + issues: [], + summary: { + totalIssues: 42, + bySeverity: { high: 2, medium: 10, low: 20, info: 10 }, + byRule: {}, + filesScanned: 3, + rulesRun: 5, + elapsedMs: 12, + }, + options: { target: ".", include: [], exclude: [], minSeverity: "low" }, +}; + +describe("badge reporter", () => { + it("renders a self-contained SVG badge", () => { + const svg = renderBadgeSvg(sampleResult); + assert.match(svg, /^ { + const json = JSON.parse(renderBadgeEndpoint(sampleResult)) as { + schemaVersion: number; + label: string; + message: string; + color: string; + }; + assert.equal(json.schemaVersion, 1); + assert.equal(json.label, "debt"); + assert.match(json.message, /42/); + assert.ok(["brightgreen", "yellow", "red"].includes(json.color)); + }); + + it("reflects configurable thresholds", () => { + const lowDebt: ScanResult = { + ...sampleResult, + summary: { + ...sampleResult.summary, + totalIssues: 5, + bySeverity: { high: 0, medium: 2, low: 2, info: 1 }, + }, + }; + const green = renderBadgeEndpoint(lowDebt, { thresholds: { greenMax: 100, yellowMax: 200 } }); + assert.match(green, /brightgreen/); + }); + + it("uses red when any high-severity issues exist", () => { + const withHigh: ScanResult = { + ...sampleResult, + summary: { + ...sampleResult.summary, + bySeverity: { ...sampleResult.summary.bySeverity, high: 1 }, + }, + }; + const json = JSON.parse(renderBadgeEndpoint(withHigh)) as { color: string }; + assert.equal(json.color, "red"); + }); + + it("allocates width for trend arrows", () => { + const svg = renderBadgeSvg(sampleResult, { trend: "up" }); + assert.match(svg, /↑/); + const widthMatch = svg.match(/width="(\d+)"/); + assert.ok(widthMatch); + const plain = renderBadgeSvg(sampleResult); + const plainWidth = plain.match(/width="(\d+)"/)?.[1]; + assert.ok(Number(widthMatch[1]) > Number(plainWidth)); + }); +}); From 75fc24fc2823b7f97da86cf8130d8a3292cc838f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 01:33:57 +0000 Subject: [PATCH 2/2] feat: add history, payoff ranking, calibrate, and triage (#250, #251, #256, #255) - Add debtlens history record/show with JSONL ledger and timeline reporters - Add payoff scoring, --sort payoff, and top-payoff report sections - Add debtlens calibrate with percentile threshold suggestions - Add interactive debtlens triage for baseline and suppression workflows Co-authored-by: ColumbusLabs --- CHANGELOG.md | 4 + src/cli/calibrate.ts | 52 +++++++++++ src/cli/commands/calibrate.ts | 31 ++++++ src/cli/commands/history.ts | 63 +++++++++++++ src/cli/commands/scan.ts | 15 +++ src/cli/commands/triage.ts | 33 +++++++ src/cli/history.ts | 51 ++++++++++ src/cli/index.ts | 6 ++ src/cli/triage.ts | 150 ++++++++++++++++++++++++++++++ src/config/defaults.ts | 2 +- src/core/calibrate.ts | 64 +++++++++++++ src/core/history.ts | 146 +++++++++++++++++++++++++++++ src/core/priority.ts | 85 +++++++++++++++++ src/core/types.ts | 8 ++ src/reporters/historyReporter.ts | 82 ++++++++++++++++ src/reporters/htmlReporter.ts | 3 + src/reporters/markdownReporter.ts | 2 + src/reporters/payoffSection.ts | 40 ++++++++ src/reporters/terminalReporter.ts | 2 + src/utils/git.ts | 6 ++ tests/core/calibrate.test.ts | 46 +++++++++ tests/core/history.test.ts | 64 +++++++++++++ tests/core/priority.test.ts | 34 +++++++ 23 files changed, 988 insertions(+), 1 deletion(-) create mode 100644 src/cli/calibrate.ts create mode 100644 src/cli/commands/calibrate.ts create mode 100644 src/cli/commands/history.ts create mode 100644 src/cli/commands/triage.ts create mode 100644 src/cli/history.ts create mode 100644 src/cli/triage.ts create mode 100644 src/core/calibrate.ts create mode 100644 src/core/history.ts create mode 100644 src/core/priority.ts create mode 100644 src/reporters/historyReporter.ts create mode 100644 src/reporters/payoffSection.ts create mode 100644 tests/core/calibrate.test.ts create mode 100644 tests/core/history.test.ts create mode 100644 tests/core/priority.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd9a16c..b0822a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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. diff --git a/src/cli/calibrate.ts b/src/cli/calibrate.ts new file mode 100644 index 0000000..d81ab3b --- /dev/null +++ b/src/cli/calibrate.ts @@ -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; +} + +export async function runCalibrate(input: CalibrateInput): Promise { + 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); +} diff --git a/src/cli/commands/calibrate.ts b/src/cli/commands/calibrate.ts new file mode 100644 index 0000000..8b4cb9a --- /dev/null +++ b/src/cli/commands/calibrate.ts @@ -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 ", "working directory", process.cwd()) + .option("--config ", "path to debtlens.config.json") + .option("--percentile ", "percentile used for suggestions (50-99)", parseInteger) + .option("--write", "merge suggested thresholds into debtlens.config.json") + .action(async (target: string, rawOptions: Record) => { + 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; + } + }); +} diff --git a/src/cli/commands/history.ts b/src/cli/commands/history.ts new file mode 100644 index 0000000..00d3061 --- /dev/null +++ b/src/cli/commands/history.ts @@ -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 ", "working directory", process.cwd()) + .option("--config ", "path to debtlens.config.json") + .option("--history-path ", "history ledger path", ".debtlens/history.jsonl") + .option("--once", "skip recording when the current git SHA already exists") + .action(async (target: string, rawOptions: Record) => { + 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 ", "working directory", process.cwd()) + .option("--history-path ", "history ledger path", ".debtlens/history.jsonl") + .option("--format ", "terminal, markdown, html, or json", "terminal") + .option("--since ", "only include entries since this git SHA prefix or ISO date") + .option("--limit ", "limit to the most recent N entries", parseInteger) + .action((rawOptions: Record) => { + 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.`); +} diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index 32cafc6..139d50c 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -7,6 +7,7 @@ import { RULE_PACK_IDS } from "../../config/packs.js"; import { resolveWorkspacePackage } from "../../config/workspaces.js"; import { DEFAULT_BASELINE_FILENAME, createBaseline, writeBaseline } from "../../core/baseline.js"; import { evaluateBudgets, renderBudgetReport } from "../../core/budgets.js"; +import { enrichIssuesWithPayoffScores, sortIssuesByPayoff } from "../../core/priority.js"; import { buildGitChurnHotspots } from "../../core/hotspots.js"; import { buildOwnershipSummary, loadCodeowners } from "../../core/ownership.js"; import { scan } from "../../core/scan.js"; @@ -96,6 +97,7 @@ export function registerScanCommand(program: Command): void { .option("--pr-comment-max-bytes ", "with --format pr-comment, cap the rendered comment body in bytes", parseInteger) .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") .action(async (target: string, rawOptions: Record) => { try { const result = await runScanCommand(target, rawOptions); @@ -244,6 +246,19 @@ export async function runScanCommand(target: string, rawOptions: Record", "working directory", process.cwd()) + .option("--config ", "path to debtlens.config.json") + .option("--baseline ", "baseline file to update", "debtlens-baseline.json") + .option("--dry-run", "preview actions without writing baseline updates") + .action(async (target: string, rawOptions: Record) => { + try { + if (!process.stdin.isTTY) { + throw new Error("debtlens triage requires an interactive terminal."); + } + const counts = await runTriage({ + target, + cwd: resolve(String(rawOptions.cwd ?? process.cwd())), + configPath: rawOptions.config ? String(rawOptions.config) : undefined, + baselinePath: rawOptions.baseline ? String(rawOptions.baseline) : undefined, + dryRun: rawOptions.dryRun === true, + cliOptions: rawOptions, + }); + process.stdout.write(`\nTriage complete: kept ${counts.kept}, baselined ${counts.baselined}, suppressions ${counts.suppressed}, skipped ${counts.skipped}.\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`DebtLens failed: ${message}\n`); + process.exitCode = 1; + } + }); +} diff --git a/src/cli/history.ts b/src/cli/history.ts new file mode 100644 index 0000000..327f874 --- /dev/null +++ b/src/cli/history.ts @@ -0,0 +1,51 @@ +import { resolve } from "node:path"; +import { loadEffectiveConfig } from "../config/loadConfig.js"; +import { mergeConfig } from "../config/mergeConfig.js"; +import { appendHistoryEntry, buildHistoryEntry, getHistoryPath, readHistoryEntries } from "../core/history.js"; +import { scan } from "../core/scan.js"; +import { getCurrentGitSha } from "../utils/git.js"; +import type { ScanResult } from "../core/types.js"; +import { loadConfiguredPlugins } from "./scanPipeline.js"; +import { parseCommaList, parseInteger, parseRuleList, parseThresholds } from "./parse.js"; +import { parseSeverity } from "../core/severity.js"; + +export interface HistoryRecordInput { + target: string; + cwd: string; + configPath?: string; + historyPath?: string; + once?: boolean; + cliOptions?: Record; +} + +export async function runHistoryRecord(input: HistoryRecordInput): Promise<{ result: ScanResult; appended: boolean; path: 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"), + pluginDetectors: pluginContribution?.detectors, + pluginThresholds: pluginContribution?.thresholds, + pluginVocabulary: pluginContribution?.vocabulary, + }); + const result = await scan(options); + const historyPath = getHistoryPath(cwd, input.historyPath); + const entry = buildHistoryEntry(result, getCurrentGitSha(cwd)); + const { appended, path } = appendHistoryEntry(historyPath, entry, { once: input.once }); + return { result, appended, path }; +} + +export function runHistoryShow(cwd: string, options: { + historyPath?: string; + since?: string; + limit?: number; +}): ReturnType { + const historyPath = getHistoryPath(resolve(cwd), options.historyPath); + return readHistoryEntries(historyPath, { since: options.since, limit: options.limit }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 703955f..ac61c29 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -13,6 +13,9 @@ import { registerPacksCommand } from "./commands/packs.js"; import { registerRulesCommand } from "./commands/rules.js"; import { registerScanCommand } from "./commands/scan.js"; import { registerSuppressCommand } from "./commands/suppress.js"; +import { registerHistoryCommand } from "./commands/history.js"; +import { registerCalibrateCommand } from "./commands/calibrate.js"; +import { registerTriageCommand } from "./commands/triage.js"; import { registerWatchCommand } from "./commands/watch.js"; const program = new Command(); @@ -35,6 +38,9 @@ registerExplainCommand(program); registerSuppressCommand(program); registerInitCommand(program); registerAdoptCommand(program); +registerHistoryCommand(program); +registerCalibrateCommand(program); +registerTriageCommand(program); if (process.argv.length <= 2) { program.help(); diff --git a/src/cli/triage.ts b/src/cli/triage.ts new file mode 100644 index 0000000..765b294 --- /dev/null +++ b/src/cli/triage.ts @@ -0,0 +1,150 @@ +import { createInterface } from "node:readline/promises"; +import { resolve } from "node:path"; +import { loadEffectiveConfig } from "../config/loadConfig.js"; +import { mergeConfig } from "../config/mergeConfig.js"; +import { DEFAULT_BASELINE_FILENAME, createBaseline, loadBaseline, writeBaseline } from "../core/baseline.js"; +import { scan } from "../core/scan.js"; +import type { DebtIssue, ScanOptions } from "../core/types.js"; +import { loadConfiguredPlugins } from "./scanPipeline.js"; +import { runSuppress } from "./suppress.js"; +import { parseCommaList, parseRuleList, parseThresholds } from "./parse.js"; +import { parseSeverity } from "../core/severity.js"; + +export interface TriageInput { + target: string; + cwd: string; + configPath?: string; + baselinePath?: string; + dryRun?: boolean; + cliOptions?: Record; + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; +} + +export interface TriageActionResult { + kept: number; + baselined: number; + suppressed: number; + skipped: number; +} + +export async function runTriage(input: TriageInput): Promise { + if (input.input && "isTTY" in input.input && input.input.isTTY === false) { + throw new Error("debtlens triage requires an interactive terminal."); + } + 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"), + pluginDetectors: pluginContribution?.detectors, + pluginThresholds: pluginContribution?.thresholds, + pluginVocabulary: pluginContribution?.vocabulary, + }); + const result = await scan(options); + const issues = [...result.issues]; + const baselinePath = resolve(cwd, input.baselinePath ?? DEFAULT_BASELINE_FILENAME); + const baseline = loadBaseline(cwd, baselinePath); + const fingerprints = new Set(Object.keys(baseline.fingerprints)); + 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, + }); + + 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 answer = (await rl.question("Action [k]eep [b]aseline [s]uppress [n]ext [q]uit [B]atch rule: ")).trim().toLowerCase(); + + if (answer === "q" || answer === "quit") break; + if (answer === "n" || answer === "next" || answer === "") { + counts.skipped += 1; + continue; + } + if (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(); + for (let cursor = index; cursor < issues.length; cursor += 1) { + const candidate = issues[cursor]; + if (!candidate || candidate.ruleId !== issue.ruleId) continue; + applyTriageAction(candidate, batchAction, { + dryRun: input.dryRun, + baseline, + fingerprints, + suppressions, + counts, + }); + } + break; + } + + applyTriageAction(issue, answer, { + dryRun: input.dryRun, + baseline, + fingerprints, + suppressions, + counts, + }); + } + } finally { + 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); + } + } + + return counts; +} + +function applyTriageAction( + issue: DebtIssue, + action: string, + context: { + dryRun?: boolean; + baseline: ReturnType; + fingerprints: Set; + suppressions: string[]; + counts: TriageActionResult; + }, +): void { + const fingerprint = issue.fingerprint ?? issue.id; + if (action === "b" || action === "baseline") { + context.baseline.fingerprints[fingerprint] = (context.baseline.fingerprints[fingerprint] ?? 0) + 1; + context.fingerprints.add(fingerprint); + context.counts.baselined += 1; + return; + } + if (action === "s" || action === "suppress") { + const reason = "triaged via debtlens triage"; + context.suppressions.push(runSuppress({ ruleId: issue.ruleId, reason })); + context.counts.suppressed += 1; + return; + } + context.counts.kept += 1; +} + +function formatIssue(issue: DebtIssue, index: number, total: number): string { + const location = issue.location ? `${issue.file}:${issue.location.startLine}` : issue.file; + return [ + `[${index}/${total}] ${issue.severity.toUpperCase()} ${issue.ruleName} (${issue.ruleId})`, + location, + issue.message, + ...(issue.suggestion ? [`Suggestion: ${issue.suggestion}`] : []), + ].join("\n"); +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 091e909..6ee287c 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -1,7 +1,7 @@ import type { DebtLensConfig } from "../core/types.js"; import { getLanguageDefinition } from "../core/languages.js"; -export const defaultConfig: Required> = { +export const defaultConfig: Required> = { include: getLanguageDefinition("tsjs").includeGlobs, exclude: [ "node_modules/**", diff --git a/src/core/calibrate.ts b/src/core/calibrate.ts new file mode 100644 index 0000000..046e0bc --- /dev/null +++ b/src/core/calibrate.ts @@ -0,0 +1,64 @@ +import { buildThresholdSuggestions, type ThresholdSuggestion } from "../cli/adoptionThresholds.js"; +import type { ScanOptions, ScanResult } from "../core/types.js"; + +export interface CalibrateOptions { + percentile: number; +} + +export interface CalibrateResult { + suggestions: ThresholdSuggestion[]; + percentile: number; +} + +export function buildCalibrateSuggestions( + result: ScanResult, + options: ScanOptions, + calibrateOptions: CalibrateOptions, +): CalibrateResult { + const base = buildThresholdSuggestions(result, options); + const percentile = clampPercentile(calibrateOptions.percentile); + const suggestions = base.map((suggestion) => { + const observed = interpolatePercentile(suggestion.observedP90, percentile); + const suggested = Math.max(Math.ceil(observed * 1.05), Math.ceil(suggestion.current)); + return { + ...suggestion, + observedP90: observed, + suggested, + }; + }); + return { suggestions, percentile }; +} + +export function renderCalibrateReport(result: CalibrateResult): string { + if (result.suggestions.length === 0) { + return `No threshold suggestions at the p${result.percentile} percentile. Current defaults already match observed distributions.\n`; + } + const lines = [ + `DebtLens calibrate (p${result.percentile})`, + "", + "Threshold".padEnd(34), + "Current", + "Suggested", + "Samples", + "-".repeat(34), + ...result.suggestions.map((suggestion) => + `${suggestion.key.padEnd(34)} ${String(suggestion.current).padEnd(7)} ${String(suggestion.suggested).padEnd(9)} ${suggestion.samples}`, + ), + "", + "Suggested config snippet:", + JSON.stringify({ + thresholds: Object.fromEntries(result.suggestions.map((suggestion) => [suggestion.key, suggestion.suggested])), + }, null, 2), + ]; + return `${lines.join("\n")}\n`; +} + +function clampPercentile(value: number): number { + if (!Number.isFinite(value)) return 90; + return Math.min(99, Math.max(50, Math.round(value))); +} + +function interpolatePercentile(observedP90: number, percentile: number): number { + const scale = percentile / 90; + return observedP90 * scale; +} diff --git a/src/core/history.ts b/src/core/history.ts new file mode 100644 index 0000000..e2ce348 --- /dev/null +++ b/src/core/history.ts @@ -0,0 +1,146 @@ +import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { summarizeIssues } from "./issueAggregates.js"; +import type { DebtIssue, ScanResult, Severity } from "./types.js"; + +export interface HistoryEntry { + timestamp: string; + gitSha?: string; + totalIssues: number; + bySeverity: Record; + byRule: Record; + byDirectory: Record; +} + +export interface HistoryReadOptions { + since?: string; + limit?: number; +} + +export const DEFAULT_HISTORY_PATH = ".debtlens/history.jsonl"; + +export function getHistoryPath(cwd: string, explicitPath?: string): string { + return resolve(cwd, explicitPath ?? DEFAULT_HISTORY_PATH); +} + +export function buildHistoryEntry(result: ScanResult, gitSha?: string): HistoryEntry { + const summary = summarizeIssues(result.issues); + return { + timestamp: new Date().toISOString(), + ...(gitSha ? { gitSha } : {}), + totalIssues: summary.totalIssues, + bySeverity: summary.bySeverity, + byRule: summary.byRule, + byDirectory: groupIssuesByDirectory(result.issues), + }; +} + +export function readHistoryEntries(historyPath: string, options: HistoryReadOptions = {}): HistoryEntry[] { + if (!existsSync(historyPath)) return []; + const lines = readFileSync(historyPath, "utf8") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const entries = lines.map((line, index) => parseHistoryLine(line, index + 1)); + const filtered = options.since ? filterSince(entries, options.since) : entries; + const limited = options.limit && options.limit > 0 ? filtered.slice(-options.limit) : filtered; + return limited; +} + +export function appendHistoryEntry( + historyPath: string, + entry: HistoryEntry, + options: { once?: boolean } = {}, +): { appended: boolean; path: string } { + if (options.once && entry.gitSha) { + const existing = readHistoryEntries(historyPath); + if (existing.some((candidate) => candidate.gitSha === entry.gitSha)) { + return { appended: false, path: historyPath }; + } + } + + mkdirSync(dirname(historyPath), { recursive: true }); + const line = `${JSON.stringify(entry)}\n`; + if (!existsSync(historyPath)) { + writeAtomic(historyPath, line); + return { appended: true, path: historyPath }; + } + + const tempPath = `${historyPath}.${process.pid}.${Date.now()}.tmp`; + try { + const current = readFileSync(historyPath, "utf8"); + writeFileSync(tempPath, `${current}${line}`, "utf8"); + renameSync(tempPath, historyPath); + } catch (error) { + try { + unlinkSync(tempPath); + } catch { + // Best-effort cleanup. + } + appendFileSync(historyPath, line, "utf8"); + if (error instanceof Error) throw error; + } + return { appended: true, path: historyPath }; +} + +function parseHistoryLine(line: string, lineNumber: number): HistoryEntry { + try { + const parsed = JSON.parse(line) as HistoryEntry; + if (!parsed.timestamp || typeof parsed.totalIssues !== "number") { + throw new Error("missing required fields"); + } + return { + timestamp: parsed.timestamp, + gitSha: parsed.gitSha, + totalIssues: parsed.totalIssues, + bySeverity: parsed.bySeverity ?? { high: 0, medium: 0, low: 0, info: 0 }, + byRule: parsed.byRule ?? {}, + byDirectory: parsed.byDirectory ?? {}, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid history entry on line ${lineNumber}: ${message}`); + } +} + +function filterSince(entries: HistoryEntry[], since: string): HistoryEntry[] { + const sinceDate = Date.parse(since); + if (Number.isFinite(sinceDate)) { + return entries.filter((entry) => Date.parse(entry.timestamp) >= sinceDate); + } + const sha = since.trim().toLowerCase(); + const index = entries.findIndex((entry) => entry.gitSha?.toLowerCase().startsWith(sha)); + if (index === -1) return entries; + return entries.slice(index); +} + +function groupIssuesByDirectory(issues: DebtIssue[]): Record { + const counts = new Map(); + for (const issue of issues) { + const directory = topLevelDirectory(issue.file); + counts.set(directory, (counts.get(directory) ?? 0) + 1); + } + return Object.fromEntries([...counts.entries()].sort((left, right) => left[0].localeCompare(right[0]))); +} + +function topLevelDirectory(file: string): string { + const normalized = file.replaceAll("\\", "/"); + const parts = normalized.split("/"); + if (parts.length <= 1) return normalized; + return parts.slice(0, 2).join("/"); +} + +function writeAtomic(path: string, contents: string): void { + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + try { + writeFileSync(tempPath, contents, "utf8"); + renameSync(tempPath, path); + } catch (error) { + try { + unlinkSync(tempPath); + } catch { + // Best-effort cleanup. + } + throw error; + } +} diff --git a/src/core/priority.ts b/src/core/priority.ts new file mode 100644 index 0000000..8a7602e --- /dev/null +++ b/src/core/priority.ts @@ -0,0 +1,85 @@ +import type { DebtIssue, ScanHotspotSummary, Severity } from "./types.js"; + +const defaultSeverityWeight: Record = { + high: 16, + medium: 8, + low: 3, + info: 1, +}; + +export interface PriorityWeights { + severity?: Partial>; + churn?: number; + age?: number; +} + +export const defaultPriorityWeights: Required = { + severity: defaultSeverityWeight, + churn: 1, + age: 0.5, +}; + +export function computePayoffScore( + issue: DebtIssue, + context: { + churnByFile?: Map; + weights?: PriorityWeights; + } = {}, +): number { + const weights = { + severity: { ...defaultSeverityWeight, ...(context.weights?.severity ?? {}) }, + churn: context.weights?.churn ?? defaultPriorityWeights.churn, + age: context.weights?.age ?? defaultPriorityWeights.age, + }; + const severityFactor = weights.severity[issue.severity] ?? 1; + const confidenceFactor = Math.max(0.35, issue.confidence); + const churnMetric = context.churnByFile?.get(issue.file) + ?? context.churnByFile?.get(normalizeRepositoryPath(issue.file)) + ?? 0; + const churnFactor = 1 + Math.log2(1 + churnMetric) * weights.churn; + const ageFactor = issue.introducedDaysAgo !== undefined + ? 1 + Math.min(issue.introducedDaysAgo / 365, 2) * weights.age + : 1; + return Number((severityFactor * confidenceFactor * churnFactor * ageFactor).toFixed(4)); +} + +export function enrichIssuesWithPayoffScores( + issues: DebtIssue[], + context: { + hotspots?: ScanHotspotSummary; + weights?: PriorityWeights; + } = {}, +): void { + const churnByFile = buildChurnLookup(context.hotspots); + for (const issue of issues) { + issue.payoffScore = computePayoffScore(issue, { churnByFile, weights: context.weights }); + } +} + +export function sortIssuesByPayoff(issues: DebtIssue[]): DebtIssue[] { + return [...issues].sort((left, right) => { + const scoreDelta = (right.payoffScore ?? 0) - (left.payoffScore ?? 0); + if (scoreDelta !== 0) return scoreDelta; + const fileDelta = left.file.localeCompare(right.file); + if (fileDelta !== 0) return fileDelta; + return (left.location?.startLine ?? 0) - (right.location?.startLine ?? 0); + }); +} + +export function topPayoffIssues(issues: DebtIssue[], limit = 10): DebtIssue[] { + return sortIssuesByPayoff(issues).slice(0, Math.max(0, limit)); +} + +function buildChurnLookup(hotspots?: ScanHotspotSummary): Map | undefined { + if (!hotspots?.ranking.length) return undefined; + const lookup = new Map(); + for (const hotspot of hotspots.ranking) { + lookup.set(hotspot.file, hotspot.churn.commits + hotspot.churn.changedLines / 100); + lookup.set(hotspot.repositoryPath, hotspot.churn.commits + hotspot.churn.changedLines / 100); + } + return lookup; +} + +function normalizeRepositoryPath(file: string): string { + return file.replaceAll("\\", "/"); +} diff --git a/src/core/types.ts b/src/core/types.ts index ee7ad5c..aa4c4e2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -25,6 +25,8 @@ export interface DebtIssue { location?: IssueLocation; /** Optional git blame age in whole days for the finding start line. */ introducedDaysAgo?: number; + /** Composite payoff ranking score when churn/priority data is available. */ + payoffScore?: number; evidence?: string[]; suggestion?: string; tags: string[]; @@ -101,6 +103,12 @@ export interface DebtLensConfig { greenMax?: number; yellowMax?: number; }; + /** Payoff ranking weights for `--sort payoff`. */ + priority?: { + severity?: Partial>; + churn?: number; + age?: number; + }; /** Rule id -> severity reported for that rule's issues, replacing the detector's choice. */ ruleSeverities?: Record; /** Rule id -> minimum confidence; issues from that rule below the floor are not reported. */ diff --git a/src/reporters/historyReporter.ts b/src/reporters/historyReporter.ts new file mode 100644 index 0000000..255139e --- /dev/null +++ b/src/reporters/historyReporter.ts @@ -0,0 +1,82 @@ +import type { HistoryEntry } from "../core/history.js"; +import type { Severity } from "../core/types.js"; + +export type HistoryFormat = "terminal" | "markdown" | "html" | "json"; + +export function renderHistoryReport(entries: HistoryEntry[], format: HistoryFormat): string { + if (format === "json") return `${JSON.stringify({ entries }, null, 2)}\n`; + if (format === "markdown") return renderMarkdown(entries); + if (format === "html") return renderHtml(entries); + return renderTerminal(entries); +} + +function renderTerminal(entries: HistoryEntry[]): string { + if (entries.length === 0) return "No history entries recorded yet.\n"; + const lines = [ + "DebtLens history", + "", + `${"Timestamp".padEnd(26)} ${"Total".padEnd(5)} ${"Trend".padEnd(5)} ${"High".padEnd(4)} Sparkline`, + `${"-".repeat(26)} ${"-".repeat(5)} ${"-".repeat(5)} ${"-".repeat(4)} ---------`, + ]; + for (const entry of entries) { + lines.push( + `${entry.timestamp.slice(0, 19).replace("T", " ")} ${String(entry.totalIssues).padEnd(5)} ${trendArrow(entry, entries).padEnd(5)} ${String(entry.bySeverity.high).padEnd(4)} ${sparkline(entry.totalIssues, entries)}`, + ); + } + return `${lines.join("\n")}\n`; +} + +function renderMarkdown(entries: HistoryEntry[]): string { + if (entries.length === 0) return "# DebtLens history\n\nNo entries recorded yet.\n"; + const lines = ["# DebtLens history", "", "| Timestamp | Total | High | Trend |", "| --- | ---: | ---: | --- |"]; + for (const entry of entries) { + lines.push(`| ${entry.timestamp} | ${entry.totalIssues} | ${entry.bySeverity.high} | ${trendArrow(entry, entries)} |`); + } + return `${lines.join("\n")}\n`; +} + +function renderHtml(entries: HistoryEntry[]): string { + const rows = entries.map((entry) => `
${escapeHtml(entry.timestamp)}${entry.totalIssues}${entry.bySeverity.high}${escapeHtml(trendArrow(entry, entries))}${sparklineSvg(entry.totalIssues, entries)}
`).join("\n"); + return ` +
DebtLens History + +

DebtLens history

+${rows} +
TimestampTotalHighTrendSparkline
`; +} + +function trendArrow(entry: HistoryEntry, entries: HistoryEntry[]): string { + const index = entries.indexOf(entry); + if (index <= 0) return "→"; + const previous = entries[index - 1]; + if (!previous) return "→"; + if (entry.totalIssues > previous.totalIssues) return "↑"; + if (entry.totalIssues < previous.totalIssues) return "↓"; + return "→"; +} + +function sparkline(value: number, entries: HistoryEntry[]): string { + const values = entries.map((entry) => entry.totalIssues); + const max = Math.max(...values, 1); + const height = 4; + const normalized = Math.max(1, Math.round((value / max) * height)); + return "▁▂▃▄▅▆▇█"[normalized - 1] ?? "▁"; +} + +function sparklineSvg(value: number, entries: HistoryEntry[]): string { + const values = entries.map((entry) => entry.totalIssues); + const max = Math.max(...values, 1); + const points = values.map((sample, index) => { + const x = (index / Math.max(values.length - 1, 1)) * 76 + 2; + const y = 14 - (sample / max) * 12; + return `${x},${y}`; + }).join(" "); + const marker = values.indexOf(value); + const cx = (marker / Math.max(values.length - 1, 1)) * 76 + 2; + const cy = 14 - (value / max) * 12; + return ``; +} + +function escapeHtml(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} diff --git a/src/reporters/htmlReporter.ts b/src/reporters/htmlReporter.ts index 70afe14..28fe9eb 100644 --- a/src/reporters/htmlReporter.ts +++ b/src/reporters/htmlReporter.ts @@ -1,5 +1,6 @@ import { buildDebtHeatmap, buildFixTargets } from "../core/issueAggregates.js"; import type { ScanResult, Severity } from "../core/types.js"; +import { renderPayoffSectionHtml } from "./payoffSection.js"; import { formatFilterStats } from "./filterStats.js"; import { formatSuppressionAuditSummary, @@ -28,6 +29,7 @@ export function renderHtml(result: ScanResult): string { `${escapeHtml(entry.file)}${entry.totalIssues}${escapeHtml(entry.rules.map((rule) => `${rule.ruleId} (${rule.count})`).join(", "))}` )).join("\n"); const suppressionAudit = renderSuppressionAudit(result); + const payoffSection = renderPayoffSectionHtml(result.issues); return ` @@ -103,6 +105,7 @@ ${findings} ${heatmap.map((entry) => `${escapeHtml(entry.file)}${entry.totalIssues}${entry.distinctRules}${entry.bySeverity.high}${entry.bySeverity.medium}${entry.bySeverity.low}${entry.bySeverity.info}`).join("\n")} ` : ""} + ${payoffSection} ${correlations ? `

Rule Correlations

diff --git a/src/reporters/markdownReporter.ts b/src/reporters/markdownReporter.ts index 9b5d5f7..750b3a7 100644 --- a/src/reporters/markdownReporter.ts +++ b/src/reporters/markdownReporter.ts @@ -1,5 +1,6 @@ import { buildDebtHeatmap, buildFixTargets } from "../core/issueAggregates.js"; import type { ScanResult, Severity } from "../core/types.js"; +import { renderPayoffSectionMarkdown } from "./payoffSection.js"; import { formatFilterStats } from "./filterStats.js"; import { escapeMarkdownTableCell } from "./markdownEscape.js"; import { getReviewPrompt } from "./ruleGuidance.js"; @@ -43,6 +44,7 @@ export function renderMarkdown(result: ScanResult, options: MarkdownOptions = {} renderHotspots(lines, result); renderOwnership(lines, result); + lines.push(...renderPayoffSectionMarkdown(result.issues)); renderFixTargets(lines, result); for (const severity of severityOrder) { diff --git a/src/reporters/payoffSection.ts b/src/reporters/payoffSection.ts new file mode 100644 index 0000000..8ead343 --- /dev/null +++ b/src/reporters/payoffSection.ts @@ -0,0 +1,40 @@ +import { topPayoffIssues } from "../core/priority.js"; +import type { DebtIssue, ScanResult } from "../core/types.js"; + +export function hasPayoffScores(issues: DebtIssue[]): boolean { + return issues.some((issue) => issue.payoffScore !== undefined); +} + +export function renderPayoffSectionTerminal(issues: DebtIssue[], limit = 10): string[] { + if (!hasPayoffScores(issues)) return []; + const lines = ["", "Top payoff targets:"]; + for (const issue of topPayoffIssues(issues, limit)) { + 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}`); + } + return lines; +} + +export function renderPayoffSectionMarkdown(issues: DebtIssue[], limit = 10): string[] { + if (!hasPayoffScores(issues)) return []; + const lines = ["", "## Top payoff targets", ""]; + for (const issue of topPayoffIssues(issues, limit)) { + const location = issue.location ? `${issue.file}:${issue.location.startLine}` : issue.file; + lines.push(`- **${issue.payoffScore?.toFixed(2)}** [${issue.severity}] \`${issue.ruleId}\` — \`${location}\` — ${issue.message}`); + } + return lines; +} + +export function renderPayoffSectionHtml(issues: DebtIssue[], limit = 10): string { + if (!hasPayoffScores(issues)) return ""; + const rows = topPayoffIssues(issues, limit).map((issue) => { + const location = issue.location ? `${issue.file}:${issue.location.startLine}` : issue.file; + return ``; + }).join("\n"); + return `

Top payoff targets

FileIssuesRules
${issue.payoffScore?.toFixed(2)}${issue.severity}${issue.ruleName}${location}${issue.message}
${rows}
ScoreSeverityRuleLocationMessage
`; +} + +export function scanHasPayoffData(result: ScanResult): boolean { + return hasPayoffScores(result.issues); +} diff --git a/src/reporters/terminalReporter.ts b/src/reporters/terminalReporter.ts index 12f518c..7bb7096 100644 --- a/src/reporters/terminalReporter.ts +++ b/src/reporters/terminalReporter.ts @@ -1,6 +1,7 @@ import { groupIssuesByFile, groupIssuesByRule } from "../core/issueAggregates.js"; import type { DebtIssue, ScanResult, Severity, TerminalGroupBy } from "../core/types.js"; import { createColorizer } from "../utils/color.js"; +import { renderPayoffSectionTerminal } from "./payoffSection.js"; import { formatFilterStats } from "./filterStats.js"; import { formatSuppressionAuditSummary, @@ -41,6 +42,7 @@ export function renderTerminal( renderHotspots(lines, result, color); renderOwnership(lines, result, color); + lines.push(...renderPayoffSectionTerminal(result.issues)); if (options.groupBy === "rule") { renderGroups(lines, result.issues, groupIssuesByRule(result.issues), "rule", color); diff --git a/src/utils/git.ts b/src/utils/git.ts index a45824c..054484c 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -32,6 +32,12 @@ function gitSafe(cwd: string, args: string[]): string { } } +export function getCurrentGitSha(cwd: string): string | undefined { + if (!isGitRepo(cwd)) return undefined; + const sha = gitSafe(cwd, ["rev-parse", "HEAD"]).trim(); + return sha || undefined; +} + export function isGitRepo(cwd: string): boolean { try { return git(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true"; diff --git a/tests/core/calibrate.test.ts b/tests/core/calibrate.test.ts new file mode 100644 index 0000000..115440e --- /dev/null +++ b/tests/core/calibrate.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildCalibrateSuggestions, renderCalibrateReport } from "../../src/core/calibrate.js"; +import type { ScanOptions, ScanResult } from "../../src/core/types.js"; + +describe("calibrate", () => { + it("renders suggested thresholds from observed evidence", () => { + const result: ScanResult = { + schemaVersion: 1, + issues: [{ + id: "1", + ruleId: "large-component", + ruleName: "Large component", + severity: "medium", + confidence: 0.8, + message: "big", + file: "src/Dashboard.tsx", + tags: [], + evidence: ["Lines: 300 / 250", "Branch points: 20 / 16"], + }], + summary: { + totalIssues: 1, + bySeverity: { high: 0, medium: 1, low: 0, info: 0 }, + byRule: { "large-component": 1 }, + filesScanned: 1, + rulesRun: 1, + elapsedMs: 1, + }, + options: { target: ".", include: [], exclude: [], minSeverity: "low" }, + }; + const options: ScanOptions = { + cwd: "/", + target: ".", + include: [], + exclude: [], + minSeverity: "low", + thresholds: { + "large-component.maxLines": 250, + "large-component.maxBranches": 16, + }, + }; + const calibrate = buildCalibrateSuggestions(result, options, { percentile: 90 }); + const report = renderCalibrateReport(calibrate); + assert.match(report, /large-component\.maxLines/); + }); +}); diff --git a/tests/core/history.test.ts b/tests/core/history.test.ts new file mode 100644 index 0000000..c9e24ef --- /dev/null +++ b/tests/core/history.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { appendHistoryEntry, buildHistoryEntry, readHistoryEntries } from "../../src/core/history.js"; +import type { ScanResult } from "../../src/core/types.js"; +import { renderHistoryReport } from "../../src/reporters/historyReporter.js"; + +const sampleResult: ScanResult = { + schemaVersion: 1, + issues: [ + { id: "1", ruleId: "todo-comment", ruleName: "Todo", severity: "low", confidence: 1, message: "todo", file: "src/a.ts", tags: [] }, + ], + summary: { + totalIssues: 1, + bySeverity: { high: 0, medium: 0, low: 1, info: 0 }, + byRule: { "todo-comment": 1 }, + filesScanned: 1, + rulesRun: 1, + elapsedMs: 1, + }, + options: { target: ".", include: [], exclude: [], minSeverity: "low" }, +}; + +describe("history ledger", () => { + it("records and reads entries", () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-history-")); + const path = join(dir, "history.jsonl"); + try { + const entry = buildHistoryEntry(sampleResult, "abc123"); + appendHistoryEntry(path, entry); + const entries = readHistoryEntries(path); + assert.equal(entries.length, 1); + assert.equal(entries[0]?.gitSha, "abc123"); + assert.equal(entries[0]?.byDirectory["src/a.ts"], 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("skips duplicate SHA when --once semantics are used", () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-history-once-")); + const path = join(dir, "history.jsonl"); + try { + const entry = buildHistoryEntry(sampleResult, "abc123"); + appendHistoryEntry(path, entry); + const second = appendHistoryEntry(path, entry, { once: true }); + assert.equal(second.appended, false); + assert.equal(readHistoryEntries(path).length, 1); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("renders terminal and html history reports", () => { + const entries = [ + { timestamp: "2026-06-01T00:00:00.000Z", totalIssues: 10, bySeverity: { high: 1, medium: 2, low: 5, info: 2 }, byRule: {}, byDirectory: {} }, + { timestamp: "2026-06-02T00:00:00.000Z", totalIssues: 8, bySeverity: { high: 0, medium: 2, low: 4, info: 2 }, byRule: {}, byDirectory: {} }, + ]; + assert.match(renderHistoryReport(entries, "terminal"), /DebtLens history/); + assert.match(renderHistoryReport(entries, "html"), //); + }); +}); diff --git a/tests/core/priority.test.ts b/tests/core/priority.test.ts new file mode 100644 index 0000000..4350e18 --- /dev/null +++ b/tests/core/priority.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { computePayoffScore, sortIssuesByPayoff } from "../../src/core/priority.js"; +import type { DebtIssue } from "../../src/core/types.js"; + +const baseIssue: DebtIssue = { + id: "1", + ruleId: "todo-comment", + ruleName: "Todo", + severity: "medium", + confidence: 0.8, + message: "todo", + file: "src/a.ts", + tags: [], +}; + +describe("payoff ranking", () => { + it("ranks higher severity and churn ahead of lower-noise findings", () => { + const hot: DebtIssue = { ...baseIssue, id: "hot", severity: "high", file: "src/hot.ts" }; + const cold: DebtIssue = { ...baseIssue, id: "cold", severity: "low", file: "src/cold.ts" }; + const churnByFile = new Map([["src/hot.ts", 20]]); + hot.payoffScore = computePayoffScore(hot, { churnByFile }); + cold.payoffScore = computePayoffScore(cold, { churnByFile }); + const sorted = sortIssuesByPayoff([cold, hot]); + assert.equal(sorted[0]?.id, "hot"); + }); + + it("sorts deterministically by score then file", () => { + const left: DebtIssue = { ...baseIssue, id: "left", file: "src/a.ts", payoffScore: 5 }; + const right: DebtIssue = { ...baseIssue, id: "right", file: "src/b.ts", payoffScore: 5 }; + const sorted = sortIssuesByPayoff([right, left]); + assert.equal(sorted[0]?.id, "left"); + }); +});