From 1d4d0a130ac24adf28628dac87ccbd2a332d205f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 01:24:18 +0000 Subject: [PATCH] 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)); + }); +});