Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/example-report.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DebtLens Report

Scanned **3** files with **32** rules in **162ms**.
Scanned **3** files with **35** rules in **162ms**.

## Summary

Expand Down
43 changes: 43 additions & 0 deletions docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
100 changes: 100 additions & 0 deletions schema/debtlens.config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
}
}
}
}
36 changes: 35 additions & 1 deletion src/cli/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down Expand Up @@ -57,7 +59,7 @@ export function registerScanCommand(program: Command): void {
.option("--rules <rules>", `comma-separated rule ids. Available: ${detectorIds.join(", ")}`)
.option("--threshold <thresholds>", "comma-separated key=value threshold overrides")
.option("--max-files <count>", "maximum files to scan", parseInteger)
.option("--format <format>", "terminal, json, markdown, pr-comment, sarif, html, junit, or gitlab-codequality", "terminal")
.option("--format <format>", "terminal, json, markdown, pr-comment, sarif, html, junit, gitlab-codequality, or badge", "terminal")
.option("-o, --output <path>", "write the report to a file instead of stdout")
.option("--fail-on <severity>", "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)
Expand Down Expand Up @@ -93,6 +95,7 @@ export function registerScanCommand(program: Command): void {
.option("--pr-comment-max-findings <count>", "with --format pr-comment, cap detailed findings and summarize omitted findings", parseNonNegativeInteger)
.option("--pr-comment-max-bytes <count>", "with --format pr-comment, cap the rendered comment body in bytes", parseInteger)
.option("--pr-comment-full-report-url <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<string, unknown>) => {
try {
const result = await runScanCommand(target, rawOptions);
Expand Down Expand Up @@ -241,6 +244,19 @@ export async function runScanCommand(target: string, rawOptions: Record<string,
enrichWithHotspots(cwd, options, reported, rawOptions, writeStderr);
enrichWithOwnership(cwd, options, reported, rawOptions, writeStderr);

const budgetEvaluation = evaluateBudgets(reported, options.budgets);
const budgetReportOnly = rawOptions.budgetReport === true;

if (budgetReportOnly && budgetEvaluation) {
return {
report: renderBudgetReport(budgetEvaluation),
exitCode: 0,
stderr: stderrChunks.join(""),
};
}

const badgeThresholds = parseBadgeThresholds(fileConfig.badge);

const report = renderReport(reported, format, {
color: rawOptions.color !== false && format === "terminal" && process.stdout.isTTY === true,
quiet: rawOptions.quiet === true,
Expand All @@ -253,15 +269,33 @@ export async function runScanCommand(target: string, rawOptions: Record<string,
prCommentMaxFindings: rawOptions.prCommentMaxFindings as number | undefined,
prCommentMaxBytes: rawOptions.prCommentMaxBytes as number | undefined,
prCommentArtifactLink: rawOptions.prCommentFullReportUrl ? String(rawOptions.prCommentFullReportUrl) : undefined,
badgeThresholds,
});

if (format === "badge" && rawOptions.output) {
const outputPath = resolve(cwd, String(rawOptions.output));
const jsonPath = outputPath.endsWith(".json")
? outputPath
: outputPath.endsWith(".svg")
? outputPath.replace(/\.svg$/, ".json")
: `${outputPath.replace(/\.(svg|json)$/i, "")}.json`;
mkdirSync(dirname(jsonPath), { recursive: true });
writeFileSync(jsonPath, renderBadgeEndpoint(reported, { thresholds: badgeThresholds }), "utf8");
}

let exitCode = 0;
if (failOn && reported.issues.some((issue) => shouldFailOnIssue(issue, failOn, failOnConfidence))) {
exitCode = 1;
}
if (rawOptions.failOnRegression === true && shouldFailOnRegression(reported)) {
exitCode = 1;
}
if (budgetEvaluation?.breached && !budgetReportOnly) {
for (const message of budgetEvaluation.messages) {
writeStderr(`DebtLens budget breach: ${message}\n`);
}
exitCode = 1;
}

return {
report,
Expand Down
4 changes: 2 additions & 2 deletions src/cli/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ export function parseConfidence(value: string): number {
}

export function parseFormat(value: string): OutputFormat {
if (value === "terminal" || value === "json" || value === "markdown" || value === "pr-comment" || value === "sarif" || value === "html" || value === "junit" || value === "gitlab-codequality") return value;
throw new Error(`Invalid format "${value}". Expected terminal, json, markdown, pr-comment, sarif, html, junit, or gitlab-codequality.`);
if (value === "terminal" || value === "json" || value === "markdown" || value === "pr-comment" || value === "sarif" || value === "html" || value === "junit" || value === "gitlab-codequality" || value === "badge") return value;
throw new Error(`Invalid format "${value}". Expected terminal, json, markdown, pr-comment, sarif, html, junit, gitlab-codequality, or badge.`);
}

export function parseGroupBy(value: string): TerminalGroupBy {
Expand Down
9 changes: 8 additions & 1 deletion src/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DebtLensConfig } from "../core/types.js";
import { getLanguageDefinition } from "../core/languages.js";

export const defaultConfig: Required<Omit<DebtLensConfig, "pack" | "failOn" | "failOnConfidence" | "gatePreset" | "pluginApiVersion" | "plugins" | "ruleSeverities" | "ruleConfidenceFloors">> = {
export const defaultConfig: Required<Omit<DebtLensConfig, "pack" | "failOn" | "failOnConfidence" | "gatePreset" | "pluginApiVersion" | "plugins" | "ruleSeverities" | "ruleConfidenceFloors" | "budgets" | "badge">> = {
include: getLanguageDefinition("tsjs").includeGlobs,
exclude: [
"node_modules/**",
Expand Down Expand Up @@ -90,6 +90,13 @@ export const defaultConfig: Required<Omit<DebtLensConfig, "pack" | "failOn" | "f
"floating-promise.maxPerFile": 12,
"commented-out-code.minLines": 2,
"commented-out-code.maxPerFile": 12,
"long-parameter-list.maxParams": 5,
"long-parameter-list.maxBooleans": 2,
"god-file.maxLines": 400,
"god-file.maxExports": 10,
"god-file.maxTopLevelDecls": 12,
"god-file.minAxes": 3,
"cognitive-complexity.max": 15,
},
maxFiles: 2000,
respectGitignore: false,
Expand Down
2 changes: 2 additions & 0 deletions src/config/loadConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export function mergeDebtLensConfig(base: DebtLensConfig, override: DebtLensConf
todoComment: mergeRecord(base.todoComment, override.todoComment),
ruleSeverities: mergeRecord(base.ruleSeverities, override.ruleSeverities),
ruleConfidenceFloors: mergeRecord(base.ruleConfidenceFloors, override.ruleConfidenceFloors),
budgets: mergeRecord(base.budgets, override.budgets),
badge: mergeRecord(base.badge, override.badge),
});
}

Expand Down
1 change: 1 addition & 0 deletions src/config/mergeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function mergeConfig(target: string, fileConfig: DebtLensConfig, cliOptio
pluginDetectors: cliOptions.pluginDetectors,
ruleSeverities: validateRuleSeverities(fileConfig.ruleSeverities),
ruleConfidenceFloors: validateRuleConfidenceFloors(fileConfig.ruleConfidenceFloors),
budgets: fileConfig.budgets,
};
}

Expand Down
3 changes: 3 additions & 0 deletions src/config/packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const CORE_RULES = [
"swallowed-error",
"floating-promise",
"commented-out-code",
"long-parameter-list",
"god-file",
"cognitive-complexity",
] as const;

const REACT_RULES = [
Expand Down
22 changes: 22 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,28 @@ export function buildConfigSchema(): Record<string, unknown> {
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 },
},
},
},
};
}
Expand Down
Loading