diff --git a/CHANGELOG.md b/CHANGELOG.md index 920325e..788a61d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ All notable changes to DebtLens are documented here. This project adheres to - 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. +- `debtlens fix` dry-run autofix allowlist for duplicated literals. +- Opt-in `feature-flags` pack with `stale-feature-flag` detector. +- `--concurrency` and `--cache-dir` scan controls for large-repo performance. - `--ownership-report` scorecards with count and payoff leaderboards. - HTML reports now include import-graph SVG and directory debt treemap views. - Core rules: `long-parameter-list`, `god-file`, and `cognitive-complexity` for function/module diff --git a/docs/example-report.md b/docs/example-report.md index 5a7a3b5..8310b31 100644 --- a/docs/example-report.md +++ b/docs/example-report.md @@ -1,6 +1,6 @@ # DebtLens Report -Scanned **3** files with **35** rules in **162ms**. +Scanned **3** files with **36** rules in **162ms**. ## Summary diff --git a/schema/debtlens.config.schema.json b/schema/debtlens.config.schema.json index 63e770a..fde724a 100644 --- a/schema/debtlens.config.schema.json +++ b/schema/debtlens.config.schema.json @@ -54,12 +54,13 @@ "compose", "ai-assisted-maintainer", "oss-maintainer", - "ai-workflow-drift" + "ai-workflow-drift", + "feature-flags" ] }, { "type": "string", - "pattern": "^\\s*(?:core|react|react-native|next|expo|node|python|python-web|vue|svelte|kotlin|swift|swiftui|ruby|rails|compose|ai-assisted-maintainer|oss-maintainer|ai-workflow-drift)(?:\\s*,\\s*(?:core|react|react-native|next|expo|node|python|python-web|vue|svelte|kotlin|swift|swiftui|ruby|rails|compose|ai-assisted-maintainer|oss-maintainer|ai-workflow-drift))*\\s*$" + "pattern": "^\\s*(?:core|react|react-native|next|expo|node|python|python-web|vue|svelte|kotlin|swift|swiftui|ruby|rails|compose|ai-assisted-maintainer|oss-maintainer|ai-workflow-drift|feature-flags)(?:\\s*,\\s*(?:core|react|react-native|next|expo|node|python|python-web|vue|svelte|kotlin|swift|swiftui|ruby|rails|compose|ai-assisted-maintainer|oss-maintainer|ai-workflow-drift|feature-flags))*\\s*$" } ], "description": "Built-in rule pack preset, or a comma-separated list of presets. Explicit rules override the pack." @@ -137,7 +138,8 @@ "compose-large-composable", "compose-state-hoisting", "ai-instruction-duplication", - "ai-instruction-contradiction" + "ai-instruction-contradiction", + "stale-feature-flag" ] }, { @@ -937,6 +939,14 @@ "medium", "high" ] + }, + "stale-feature-flag": { + "enum": [ + "info", + "low", + "medium", + "high" + ] } }, "additionalProperties": { @@ -1286,6 +1296,11 @@ "type": "number", "minimum": 0, "maximum": 1 + }, + "stale-feature-flag": { + "type": "number", + "minimum": 0, + "maximum": 1 } }, "additionalProperties": { @@ -1441,6 +1456,43 @@ "minimum": 0 } } + }, + "priority": { + "type": "object", + "description": "Payoff ranking weights for --sort payoff.", + "additionalProperties": false, + "properties": { + "churn": { + "type": "number", + "minimum": 0 + }, + "age": { + "type": "number", + "minimum": 0 + }, + "severity": { + "type": "object", + "additionalProperties": false, + "properties": { + "info": { + "type": "number", + "minimum": 0 + }, + "low": { + "type": "number", + "minimum": 0 + }, + "medium": { + "type": "number", + "minimum": 0 + }, + "high": { + "type": "number", + "minimum": 0 + } + } + } + } } } } diff --git a/schema/debtlens.scan-result.schema.json b/schema/debtlens.scan-result.schema.json index f84b175..6f65924 100644 --- a/schema/debtlens.scan-result.schema.json +++ b/schema/debtlens.scan-result.schema.json @@ -69,6 +69,10 @@ "type": "integer", "minimum": 0 }, + "payoffScore": { + "type": "number", + "minimum": 0 + }, "location": { "type": "object", "additionalProperties": false, @@ -202,6 +206,10 @@ "type": "integer", "minimum": 0 }, + "payoffScore": { + "type": "number", + "minimum": 0 + }, "location": { "type": "object", "additionalProperties": false, @@ -651,6 +659,55 @@ } } }, + "importGraph": { + "type": "object", + "additionalProperties": false, + "required": [ + "nodes", + "edges", + "cycles" + ], + "properties": { + "nodes": { + "type": "array", + "items": { + "type": "string" + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "from", + "to", + "inCycle" + ], + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "inCycle": { + "type": "boolean" + } + } + } + }, + "cycles": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, "hotspots": { "type": "object", "additionalProperties": false, @@ -1238,6 +1295,10 @@ }, "parallel": { "type": "boolean" + }, + "concurrency": { + "type": "integer", + "minimum": 1 } } } diff --git a/src/cli/adoptionThresholds.ts b/src/cli/adoptionThresholds.ts index 3a41d5c..16c8eb3 100644 --- a/src/cli/adoptionThresholds.ts +++ b/src/cli/adoptionThresholds.ts @@ -6,6 +6,7 @@ export interface ThresholdSuggestion { suggested: number; observedP90: number; samples: number; + observedValues?: number[]; } interface EvidenceThreshold { @@ -51,7 +52,7 @@ export function buildThresholdSuggestions(result: ScanResult, options: ScanOptio const current = options.thresholds[key]; const observedP90 = percentile(values, 0.9); const suggested = Math.max(Math.ceil(observedP90 * 1.1), Math.ceil(current ?? 0)); - return { key, current: current ?? 0, suggested, observedP90, samples: values.length }; + return { key, current: current ?? 0, suggested, observedP90, samples: values.length, observedValues: [...values] }; }) .filter((suggestion) => suggestion.current > 0 && suggestion.suggested > suggestion.current) .sort((left, right) => left.key.localeCompare(right.key)); diff --git a/src/cli/commands/fix.ts b/src/cli/commands/fix.ts new file mode 100644 index 0000000..af71551 --- /dev/null +++ b/src/cli/commands/fix.ts @@ -0,0 +1,48 @@ +import type { Command } from "commander"; +import { resolve } from "node:path"; +import { loadEffectiveConfig } from "../../config/loadConfig.js"; +import { mergeConfig } from "../../config/mergeConfig.js"; +import { runFix } from "../fix.js"; +import { parseCommaList, parseRuleList, parseThresholds } from "../parse.js"; +import { parseSeverity } from "../../core/severity.js"; +import { loadConfiguredPlugins } from "../scanPipeline.js"; + +export function registerFixCommand(program: Command): void { + program.command("fix") + .description("Apply a conservative allowlist of mechanical autofixes (dry-run by default).") + .argument("[target]", "directory or file to scan", ".") + .option("--rules ", "comma-separated fixable rule ids (duplicated-literal, dead-abstraction)") + .option("--fix", "write fixes to disk (default is dry-run)") + .option("--cwd ", "working directory", process.cwd()) + .option("--config ", "path to debtlens.config.json") + .action(async (target: string, rawOptions: Record) => { + try { + const cwd = resolve(String(rawOptions.cwd ?? process.cwd())); + const effectiveConfig = loadEffectiveConfig(cwd, rawOptions.config ? String(rawOptions.config) : undefined); + const pluginContribution = await loadConfiguredPlugins(cwd, rawOptions, effectiveConfig.config, effectiveConfig.pluginConfigDir); + const options = mergeConfig(target, effectiveConfig.config, { + cwd, + include: parseCommaList(rawOptions.include as string | undefined), + exclude: parseCommaList(rawOptions.exclude as string | undefined), + rules: parseRuleList(rawOptions.rules as string | undefined), + thresholds: parseThresholds(rawOptions.threshold as string | undefined), + minSeverity: parseSeverity(String(rawOptions.minSeverity ?? "low"), "low"), + pluginDetectors: pluginContribution?.detectors, + }); + const result = await runFix(options, { + rules: parseRuleList(rawOptions.rules as string | undefined), + dryRun: rawOptions.fix !== true, + }); + if (result.diffs.length === 0) { + process.stdout.write(result.dryRun ? "No fixable findings.\n" : "No fixes applied.\n"); + return; + } + process.stdout.write(`${result.dryRun ? "Dry-run" : "Applied"} ${result.filesTouched} file(s):\n`); + for (const diff of result.diffs) process.stdout.write(`${diff}\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/commands/scan.ts b/src/cli/commands/scan.ts index d262065..8aab6f3 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -82,6 +82,8 @@ export function registerScanCommand(program: Command): void { .option("--audit-suppressions", "include used and unused inline suppression directives in scan output") .option("--cache [path]", "reuse unchanged scan results from a content-hash cache") .option("--parallel", "run detectors concurrently after source loading") + .option("--concurrency ", "maximum concurrent detector runs for large scans (1 disables)", parseInteger) + .option("--cache-dir ", "shared cache directory for CI artifact restore") .option("--batch-size ", "load source files in bounded batches", parseInteger) .option("--blame-age", "add introducedDaysAgo metadata to JSON issues using git blame") .option("--hotspots [limit]", "rank files by current findings plus recent git churn", parseOptionalInteger) @@ -185,9 +187,11 @@ export async function runScanCommand(target: string, rawOptions: Record { + const dryRun = input.dryRun !== false; + const allowedRules = resolveFixRules(input.rules, dryRun); + const result = await scan({ ...options, rules: allowedRules }); + const project = new Project({ + compilerOptions: { + allowJs: true, + jsx: ts.JsxEmit.ReactJSX, + target: ts.ScriptTarget.ES2022, + }, + skipAddingFilesFromTsConfig: true, + }); + + const diffs: string[] = []; + const touched = new Set(); + const fixedIssues: DebtIssue[] = []; + for (const issue of result.issues) { + if (issue.ruleId === "duplicated-literal") { + const literal = extractDuplicatedLiteral(issue); + if (!literal) continue; + const files = collectIssueFiles(issue); + for (const file of files) { + const diff = fixDuplicatedLiteral(project, options, file, literal); + if (diff) { + diffs.push(diff); + touched.add(file); + fixedIssues.push(issue); + } + } + } + if (issue.ruleId === "dead-abstraction") { + const diff = fixDeadAbstraction(project, options, issue); + if (diff) { + diffs.push(diff); + touched.add(issue.file); + fixedIssues.push(issue); + } + } + } + + if (!dryRun && fixedIssues.length > 0) { + const fileContents = buildFileContentOverrides(project, options); + const verify = await scan({ ...options, rules: allowedRules, fileContents }); + for (const issue of fixedIssues) { + const stillPresent = verify.issues.some((candidate) => + candidate.ruleId === issue.ruleId + && candidate.file === issue.file + && candidate.location?.startLine === issue.location?.startLine, + ); + if (stillPresent) { + throw new Error(`Fix did not resolve ${issue.ruleId} at ${issue.file}:${issue.location?.startLine ?? "?"}`); + } + } + await project.save(); + } + + return { diffs, filesTouched: touched.size, dryRun }; +} + +function resolveFixRules(requestedRules: string[] | undefined, dryRun: boolean): string[] { + const allowed = dryRun ? PREVIEWABLE_RULES : WRITABLE_RULES; + const requested = requestedRules?.length ? requestedRules : [...allowed]; + const unsupported = requested.filter((rule) => !allowed.has(rule)); + if (unsupported.length > 0) { + const mode = dryRun ? "previewable" : "writable"; + throw new Error(`Rule(s) are not ${mode} by debtlens fix: ${unsupported.join(", ")}`); + } + return [...new Set(requested)]; +} + +function buildFileContentOverrides(project: Project, options: ScanOptions): Record { + const overrides: Record = { ...(options.fileContents ?? {}) }; + for (const source of project.getSourceFiles()) { + overrides[source.getFilePath()] = source.getFullText(); + } + return overrides; +} + +function collectIssueFiles(issue: DebtIssue): string[] { + const files = new Set([issue.file]); + for (const line of issue.evidence ?? []) { + const file = line.split(":")[0]; + if (file) files.add(file); + } + return [...files]; +} + +function extractDuplicatedLiteral(issue: DebtIssue): string | undefined { + const prefix = issue.message.match(/^(.+?) is repeated /)?.[1]; + if (!prefix) return undefined; + if (prefix.startsWith("\"") || prefix.startsWith("'")) { + try { + return JSON.parse(prefix) as string; + } catch { + return undefined; + } + } + return prefix; +} + +function fixDuplicatedLiteral(project: Project, options: ScanOptions, relativePath: string, literal: string): string | undefined { + const absolutePath = resolve(options.target, relativePath); + const source = project.addSourceFileAtPathIfExists(absolutePath) ?? project.createSourceFile(absolutePath, "", { overwrite: true }); + const constName = `SHARED_${literal.replace(/[^a-zA-Z0-9]+/g, "_").toUpperCase().slice(0, 24)}`; + if (!source.getVariableDeclaration(constName)) { + source.insertStatements(0, `const ${constName} = ${JSON.stringify(literal)};\n`); + } + const before = source.getFullText(); + for (const node of source.getDescendantsOfKind(SyntaxKind.StringLiteral)) { + if (node.getLiteralValue() === literal) { + node.replaceWithText(constName); + } + } + const after = source.getFullText(); + if (before === after) return undefined; + return `--- ${relativePath}\n+++ ${relativePath}\n${after}`; +} + +function fixDeadAbstraction(project: Project, options: ScanOptions, issue: DebtIssue): string | undefined { + const absolutePath = resolve(options.target, issue.file); + const source = project.addSourceFileAtPathIfExists(absolutePath); + if (!source) return undefined; + + const fileInfo = { + relativePath: issue.file, + absolutePath, + sourceFile: source, + content: source.getFullText(), + language: "tsjs" as const, + }; + + for (const fn of collectFunctionLikes(fileInfo)) { + const body = getFunctionBody(fn.node); + if (!body) continue; + const span = nodeLineSpan(body); + if (span.startLine !== issue.location?.startLine) continue; + + const delegation = getPassThroughDelegation(body, fn.node, fn.name); + if (!delegation) return undefined; + if (isExportedWrapper(source, fn)) return undefined; + + const before = source.getFullText(); + inlinePassThroughWrapper(source, fn, delegation.calleeText); + const after = source.getFullText(); + if (before === after) return undefined; + return `--- ${issue.file}\n+++ ${issue.file}\n${after}`; + } + + return undefined; +} + +function isExportedWrapper( + source: NonNullable>, + fn: ReturnType[number], +): boolean { + if (Node.isFunctionDeclaration(fn.declaration) && fn.declaration.isExported()) return true; + const variableStatement = Node.isVariableDeclaration(fn.declaration) + ? fn.declaration.getVariableStatement() + : undefined; + if (variableStatement?.isExported()) return true; + return source.getExportDeclarations().some((declaration) => + !declaration.getModuleSpecifierValue() + && declaration.getNamedExports().some((namedExport) => namedExport.getName() === fn.name), + ); +} + +function getPassThroughDelegation(body: MorphNode, fnNode: MorphNode, fnName: string): { calleeText: string } | undefined { + const paramNames = extractParamNames(fnNode); + if (!paramNames) return undefined; + + const expression = readSingleReturnExpression(body); + if (!expression || !Node.isCallExpression(expression)) return undefined; + + const args = expression.getArguments(); + if (args.length !== paramNames.length) return undefined; + if (!args.every((arg, index) => Node.isIdentifier(arg) && arg.getText() === paramNames[index])) { + return undefined; + } + + return { calleeText: expression.getExpression().getText() }; +} + +function readSingleReturnExpression(body: MorphNode): MorphNode | undefined { + if (Node.isBlock(body)) { + const statements = body.getStatements(); + if (statements.length !== 1) return undefined; + const only = statements[0]; + if (!only || !Node.isReturnStatement(only)) return undefined; + return only.getExpression() ?? undefined; + } + return body; +} + +function extractParamNames(node: MorphNode): string[] | null { + const parameters = Node.isFunctionDeclaration(node) || Node.isFunctionExpression(node) || Node.isArrowFunction(node) + ? node.getParameters() + : []; + const names: string[] = []; + for (const param of parameters) { + const nameNode = param.getNameNode(); + if (!Node.isIdentifier(nameNode)) return null; + names.push(nameNode.getText()); + } + return names; +} + +function inlinePassThroughWrapper( + source: ReturnType, + fn: ReturnType[number], + calleeText: string, +): void { + if (!source) return; + const fnName = fn.name; + + for (const identifier of source.getDescendantsOfKind(SyntaxKind.Identifier)) { + if (identifier.getText() !== fnName) continue; + if (isDeclarationName(identifier, fnName)) continue; + const parent = identifier.getParent(); + if (!Node.isCallExpression(parent) || parent.getExpression() !== identifier) continue; + parent.getExpression().replaceWithText(calleeText); + } + + if (Node.isFunctionDeclaration(fn.declaration)) { + fn.declaration.remove(); + return; + } + + const variableDeclaration = fn.declaration as VariableDeclaration; + variableDeclaration.remove(); +} + +function isDeclarationName(identifier: MorphNode, fnName: string): boolean { + const parent = identifier.getParent(); + if (Node.isFunctionDeclaration(parent) && parent.getName() === fnName) return true; + if (Node.isVariableDeclaration(parent) && parent.getName() === fnName) return true; + return false; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index ac61c29..0e46125 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -16,6 +16,7 @@ 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 { registerFixCommand } from "./commands/fix.js"; import { registerWatchCommand } from "./commands/watch.js"; const program = new Command(); @@ -41,6 +42,7 @@ registerAdoptCommand(program); registerHistoryCommand(program); registerCalibrateCommand(program); registerTriageCommand(program); +registerFixCommand(program); if (process.argv.length <= 2) { program.help(); diff --git a/src/cli/triage.ts b/src/cli/triage.ts index 765b294..a2b3637 100644 --- a/src/cli/triage.ts +++ b/src/cli/triage.ts @@ -2,6 +2,7 @@ import { createInterface } from "node:readline/promises"; import { resolve } from "node:path"; import { loadEffectiveConfig } from "../config/loadConfig.js"; import { mergeConfig } from "../config/mergeConfig.js"; +import { existsSync } from "node:fs"; 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"; @@ -50,7 +51,7 @@ export async function runTriage(input: TriageInput): Promise const result = await scan(options); const issues = [...result.issues]; const baselinePath = resolve(cwd, input.baselinePath ?? DEFAULT_BASELINE_FILENAME); - const baseline = loadBaseline(cwd, baselinePath); + const baseline = existsSync(baselinePath) ? loadBaseline(cwd, baselinePath) : createBaseline([]); const fingerprints = new Set(Object.keys(baseline.fingerprints)); const suppressions: string[] = []; const counts: TriageActionResult = { kept: 0, baselined: 0, suppressed: 0, skipped: 0 }; @@ -66,14 +67,15 @@ export async function runTriage(input: TriageInput): Promise 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(); + const rawAnswer = (await rl.question("Action [k]eep [b]aseline [s]uppress [n]ext [q]uit [B]atch rule: ")).trim(); + const answer = rawAnswer.toLowerCase(); if (answer === "q" || answer === "quit") break; if (answer === "n" || answer === "next" || answer === "") { counts.skipped += 1; continue; } - if (answer === "batch" || answer === "b-rule") { + if (rawAnswer === "B" || 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]; diff --git a/src/config/mergeConfig.ts b/src/config/mergeConfig.ts index cb63bca..274fb82 100644 --- a/src/config/mergeConfig.ts +++ b/src/config/mergeConfig.ts @@ -76,6 +76,8 @@ export function mergeConfig(target: string, fileConfig: DebtLensConfig, cliOptio cachePath: cliOptions.cachePath, batchSize: cliOptions.batchSize, parallel: cliOptions.parallel, + concurrency: cliOptions.concurrency, + cacheDir: cliOptions.cacheDir, pluginDetectors: cliOptions.pluginDetectors, ruleSeverities: validateRuleSeverities(fileConfig.ruleSeverities), ruleConfidenceFloors: validateRuleConfidenceFloors(fileConfig.ruleConfidenceFloors), diff --git a/src/config/packs.ts b/src/config/packs.ts index 5e7e28c..39d8ea6 100644 --- a/src/config/packs.ts +++ b/src/config/packs.ts @@ -66,6 +66,8 @@ const NODE_RULES = [ "route-sprawl", ] as const; +const FEATURE_FLAG_RULES = ["stale-feature-flag"] as const; + const AI_WORKFLOW_DRIFT_RULES = [ "ai-instruction-duplication", "ai-instruction-contradiction", @@ -331,6 +333,12 @@ export const RULE_PACKS: Record = { languages: [], includeGlobs: [...INSTRUCTION_FILE_GLOBS], }, + "feature-flags": { + id: "feature-flags", + description: "Opt-in pack for stale or hardcoded feature-flag debt.", + rules: [...FEATURE_FLAG_RULES], + languages: ["tsjs"], + }, }; export const RULE_PACK_IDS = Object.keys(RULE_PACKS); diff --git a/src/config/schema.ts b/src/config/schema.ts index 02933f1..a298587 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -212,6 +212,20 @@ export function buildConfigSchema(): Record { yellowMax: { type: "integer", minimum: 0 }, }, }, + priority: { + type: "object", + description: "Payoff ranking weights for --sort payoff.", + additionalProperties: false, + properties: { + churn: { type: "number", minimum: 0 }, + age: { type: "number", minimum: 0 }, + severity: { + type: "object", + additionalProperties: false, + properties: Object.fromEntries(severities.map((severity) => [severity, { type: "number", minimum: 0 }])), + }, + }, + }, }, }; } diff --git a/src/config/validateConfig.ts b/src/config/validateConfig.ts index 2df73fd..bcf89ef 100644 --- a/src/config/validateConfig.ts +++ b/src/config/validateConfig.ts @@ -25,6 +25,9 @@ const knownRootKeys = new Set([ "failOn", "failOnConfidence", "gatePreset", + "budgets", + "badge", + "priority", ]); export interface ConfigValidationResult { @@ -98,6 +101,9 @@ export function validateConfigShape(config: unknown): ConfigValidationResult { validateDuplicatedLiteral(errors, typed.duplicatedLiteral); validateNamingDrift(errors, typed.namingDrift); validateTodoComment(errors, typed.todoComment); + validateBudgets(errors, typed.budgets); + validateBadge(errors, typed.badge); + validatePriority(errors, typed.priority); return { valid: errors.length === 0, errors }; } @@ -239,6 +245,56 @@ function validateTodoComment(errors: string[], value: unknown): void { } } +function validateBudgets(errors: string[], value: unknown): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + errors.push("budgets must be an object"); + return; + } + for (const [pattern, budget] of Object.entries(value)) { + if (!isPlainObject(budget)) { + errors.push(`budgets.${pattern} must be an object`); + continue; + } + validateAllowedKeys(errors, `budgets.${pattern}`, budget, ["maxIssues", "maxHigh", "maxMedium"]); + validateNonNegativeInteger(errors, `budgets.${pattern}.maxIssues`, budget.maxIssues); + validateNonNegativeInteger(errors, `budgets.${pattern}.maxHigh`, budget.maxHigh); + validateNonNegativeInteger(errors, `budgets.${pattern}.maxMedium`, budget.maxMedium); + } +} + +function validateBadge(errors: string[], value: unknown): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + errors.push("badge must be an object"); + return; + } + validateAllowedKeys(errors, "badge", value, ["greenMax", "yellowMax"]); + validateNonNegativeInteger(errors, "badge.greenMax", value.greenMax); + validateNonNegativeInteger(errors, "badge.yellowMax", value.yellowMax); +} + +function validatePriority(errors: string[], value: unknown): void { + if (value === undefined) return; + if (!isPlainObject(value)) { + errors.push("priority must be an object"); + return; + } + validateAllowedKeys(errors, "priority", value, ["severity", "churn", "age"]); + validateNonNegativeNumber(errors, "priority.churn", value.churn); + validateNonNegativeNumber(errors, "priority.age", value.age); + if (value.severity !== undefined) { + if (!isPlainObject(value.severity)) { + errors.push("priority.severity must be an object"); + } else { + validateAllowedKeys(errors, "priority.severity", value.severity, [...severities]); + for (const severity of severities) { + validateNonNegativeNumber(errors, `priority.severity.${severity}`, value.severity[severity]); + } + } + } +} + function validateAllowedKeys(errors: string[], prefix: string, value: Record, allowed: string[]): void { const allowedSet = new Set(allowed); for (const key of Object.keys(value)) { @@ -248,6 +304,20 @@ function validateAllowedKeys(errors: string[], prefix: string, value: Record= min && value <= max; } diff --git a/src/core/budgets.ts b/src/core/budgets.ts index 7f73f38..92588fe 100644 --- a/src/core/budgets.ts +++ b/src/core/budgets.ts @@ -131,8 +131,13 @@ function pathMatchesPattern(path: string, pattern: string): boolean { const char = normalizedPattern[index]; const next = normalizedPattern[index + 1]; if (char === "*" && next === "*") { - expression += ".*"; - index += 1; + if (normalizedPattern[index + 2] === "/") { + expression += "(?:.*/)?"; + index += 2; + } else { + expression += ".*"; + index += 1; + } } else if (char === "*") { expression += "[^/]*"; } else { diff --git a/src/core/calibrate.ts b/src/core/calibrate.ts index 046e0bc..0d4a1cd 100644 --- a/src/core/calibrate.ts +++ b/src/core/calibrate.ts @@ -18,7 +18,7 @@ export function buildCalibrateSuggestions( const base = buildThresholdSuggestions(result, options); const percentile = clampPercentile(calibrateOptions.percentile); const suggestions = base.map((suggestion) => { - const observed = interpolatePercentile(suggestion.observedP90, percentile); + const observed = percentileValue(suggestion.observedValues ?? [suggestion.observedP90], percentile / 100); const suggested = Math.max(Math.ceil(observed * 1.05), Math.ceil(suggestion.current)); return { ...suggestion, @@ -58,7 +58,9 @@ function clampPercentile(value: number): number { return Math.min(99, Math.max(50, Math.round(value))); } -function interpolatePercentile(observedP90: number, percentile: number): number { - const scale = percentile / 90; - return observedP90 * scale; +function percentileValue(values: number[], quantile: number): number { + const sorted = [...values].filter(Number.isFinite).sort((a, b) => a - b); + if (sorted.length === 0) return 0; + const index = Math.ceil(sorted.length * quantile) - 1; + return sorted[Math.max(0, Math.min(sorted.length - 1, index))] ?? 0; } diff --git a/src/core/ownershipReport.ts b/src/core/ownershipReport.ts index 78712df..2d2b556 100644 --- a/src/core/ownershipReport.ts +++ b/src/core/ownershipReport.ts @@ -37,7 +37,11 @@ export function buildOwnershipReport(input: { }); if (!ownership) return undefined; - const owners = ownership.ownerSummaries.map((owner) => toScorecard(owner, input.result.issues, input.historyTotalsByOwner?.[owner.owner])); + const ownersByFile = new Map(ownership.files.map((file) => [file.file, file.owners])); + const owners = ownership.ownerSummaries.map((owner) => { + const ownerIssues = input.result.issues.filter((issue) => ownersByFile.get(issue.file)?.includes(owner.owner)); + return toScorecard(owner, ownerIssues, input.historyTotalsByOwner?.[owner.owner]); + }); const unownedIssues = input.result.issues.filter((issue) => { const file = ownership.files.find((entry) => entry.file === issue.file); return !file || file.owners.length === 0; @@ -72,12 +76,11 @@ function toScorecard( issues: DebtIssue[], history?: number[], ): OwnershipScorecardEntry { - const ownerIssues = issues.filter((issue) => owner.topFiles.some((file) => file.file === issue.file) || owner.owner === "unowned"); - const topRules = groupIssuesByRule(ownerIssues) + const topRules = groupIssuesByRule(issues) .map(([ruleId, ruleIssues]) => ({ ruleId, count: ruleIssues.length })) .sort((left, right) => right.count - left.count || left.ruleId.localeCompare(right.ruleId)) .slice(0, 5); - const payoffWeightedDebt = sortIssuesByPayoff(ownerIssues).reduce((total, issue) => total + (issue.payoffScore ?? 0), 0); + const payoffWeightedDebt = sortIssuesByPayoff(issues).reduce((total, issue) => total + (issue.payoffScore ?? 0), 0); return { owner: owner.owner, totalIssues: owner.totalIssues, diff --git a/src/core/parallelScan.ts b/src/core/parallelScan.ts new file mode 100644 index 0000000..da15d5d --- /dev/null +++ b/src/core/parallelScan.ts @@ -0,0 +1,23 @@ +import { availableParallelism } from "node:os"; +import type { ScanOptions } from "./types.js"; + +export function resolveConcurrency(options: ScanOptions): number { + if (options.concurrency !== undefined) return Math.max(1, options.concurrency); + return 1; +} + +export function shouldUseWorkerPool(options: ScanOptions): boolean { + return resolveConcurrency(options) > 1; +} + +export function defaultConcurrency(): number { + return Math.max(1, Math.min(availableParallelism(), 4)); +} + +export async function shardFiles(items: T[], concurrency: number): Promise { + const shards: T[][] = Array.from({ length: Math.max(1, concurrency) }, () => []); + for (let index = 0; index < items.length; index += 1) { + shards[index % shards.length]?.push(items[index] as T); + } + return shards.filter((shard) => shard.length > 0); +} diff --git a/src/core/scan.ts b/src/core/scan.ts index 28eb851..6dcba51 100644 --- a/src/core/scan.ts +++ b/src/core/scan.ts @@ -5,6 +5,7 @@ import { allDetectors } from "../detectors/index.js"; import { buildDuplicateLogicClusters, buildRuleCorrelations, summarizeIssues } from "./issueAggregates.js"; import { buildImportGraphFromFiles } from "./importGraph.js"; import { DEFAULT_SOURCE_LANGUAGE, detectSourceLanguage, languagesForDetector, parseSourceFile } from "./languages.js"; +import { resolveConcurrency } from "./parallelScan.js"; import { canonicalize, resolveFileSelection } from "./resolveFiles.js"; import { buildScanCacheKey, getScanCachePath, hashContent, readCachedScan, writeCachedScan, type FileSnapshot } from "./scanCache.js"; import { compareSeverityDesc, meetsMinSeverity } from "./severity.js"; @@ -34,7 +35,8 @@ export async function scan(options: ScanOptions): Promise { ...(cached.summary.performance ?? {}), cache: { enabled: true, hit: true, path: cachePath }, ...(options.batchSize ? { batchSize: options.batchSize } : {}), - ...(options.parallel ? { parallel: true } : {}), + ...(options.parallel || (options.concurrency ?? 0) > 1 ? { parallel: true } : {}), + ...(options.concurrency ? { concurrency: options.concurrency } : {}), }; return cached; } @@ -150,11 +152,12 @@ export async function scan(options: ScanOptions): Promise { ...(duplicateClusters.length > 0 ? { duplicateClusters } : {}), importGraph, ...(options.profile ? { profile: { ruleTimingsMs } } : {}), - ...(cachePath || options.batchSize || options.parallel ? { + ...(cachePath || options.batchSize || options.parallel || (options.concurrency ?? 0) > 1 ? { performance: { ...(cachePath ? { cache: { enabled: true, hit: false, path: cachePath } } : {}), ...(options.batchSize ? { batchSize: options.batchSize } : {}), - ...(options.parallel ? { parallel: true } : {}), + ...(options.parallel || (options.concurrency ?? 0) > 1 ? { parallel: true } : {}), + ...(options.concurrency ? { concurrency: options.concurrency } : {}), }, } : {}), }, @@ -246,8 +249,11 @@ async function runDetectors( }; }; - if (contextBase.options.parallel) { - return Promise.all(detectors.map((detector) => runOne(detector))); + if (contextBase.options.parallel || (contextBase.options.concurrency ?? 0) > 1) { + const concurrency = contextBase.options.concurrency !== undefined + ? resolveConcurrency(contextBase.options) + : Math.max(1, detectors.length); + return runWithConcurrency(detectors, concurrency, runOne); } const results: DetectorRunResult[] = []; @@ -257,6 +263,24 @@ async function runDetectors( return results; } +async function runWithConcurrency( + items: T[], + concurrency: number, + runOne: (item: T) => Promise, +): Promise { + const results: R[] = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(Math.max(1, concurrency), items.length); + await Promise.all(Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await runOne(items[index] as T); + } + })); + return results; +} + function filesForDetector(detector: Detector, files: SourceFileInfo[]): SourceFileInfo[] { const languages = languagesForDetector(detector); const allowed = new Set(languages); diff --git a/src/core/scanCache.ts b/src/core/scanCache.ts index 8a8be3f..01c7593 100644 --- a/src/core/scanCache.ts +++ b/src/core/scanCache.ts @@ -32,6 +32,7 @@ export interface FileSnapshot { } export function getScanCachePath(options: ScanOptions): string { + if (options.cacheDir) return resolve(options.cwd, options.cacheDir, "cache.json"); return resolve(options.cwd, options.cachePath ?? ".debtlens/cache.json"); } diff --git a/src/core/scanResultSchema.ts b/src/core/scanResultSchema.ts index 13529e7..ba795eb 100644 --- a/src/core/scanResultSchema.ts +++ b/src/core/scanResultSchema.ts @@ -20,6 +20,7 @@ export function buildScanResultSchema(): Record { message: { type: "string" }, file: { type: "string" }, introducedDaysAgo: { type: "integer", minimum: 0 }, + payoffScore: { type: "number", minimum: 0 }, location: { type: "object", additionalProperties: false, @@ -92,6 +93,34 @@ export function buildScanResultSchema(): Record { }, }, }; + const importGraph = { + type: "object", + additionalProperties: false, + required: ["nodes", "edges", "cycles"], + properties: { + nodes: { type: "array", items: { type: "string" } }, + edges: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["from", "to", "inCycle"], + properties: { + from: { type: "string" }, + to: { type: "string" }, + inCycle: { type: "boolean" }, + }, + }, + }, + cycles: { + type: "array", + items: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }; const fileChurnMetric = { type: "object", additionalProperties: false, @@ -308,6 +337,7 @@ export function buildScanResultSchema(): Record { }, correlations: { type: "array", items: correlation }, duplicateClusters: { type: "array", items: duplicateCluster }, + importGraph, hotspots: { type: "object", additionalProperties: false, @@ -364,6 +394,7 @@ export function buildScanResultSchema(): Record { }, batchSize: { type: "integer", minimum: 1 }, parallel: { type: "boolean" }, + concurrency: { type: "integer", minimum: 1 }, }, }, }, diff --git a/src/core/types.ts b/src/core/types.ts index d14603f..5206552 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -155,6 +155,10 @@ export interface ScanOptions { batchSize?: number; /** Run detectors concurrently after source loading. Results remain sorted deterministically. */ parallel?: boolean; + /** Worker-thread concurrency for large scans (`--concurrency`). */ + concurrency?: number; + /** Shared cache directory override (`--cache-dir`). */ + cacheDir?: string; /** Detectors contributed by config-loaded plugins, merged after built-in rules. */ pluginDetectors?: Detector[]; /** Rule id -> severity reported for that rule's issues, replacing the detector's choice. */ @@ -190,6 +194,8 @@ export interface CliOptions { cachePath?: string; batchSize?: number; parallel?: boolean; + concurrency?: number; + cacheDir?: string; pluginDetectors?: Detector[]; /** Threshold defaults contributed by plugins; user config and CLI thresholds override. */ pluginThresholds?: ScanThresholds; @@ -422,6 +428,7 @@ export interface ScanPerformance { }; batchSize?: number; parallel?: boolean; + concurrency?: number; } export interface CacheKeyInput { diff --git a/src/detectors/featureFlagDebt.ts b/src/detectors/featureFlagDebt.ts new file mode 100644 index 0000000..1f6a0b7 --- /dev/null +++ b/src/detectors/featureFlagDebt.ts @@ -0,0 +1,86 @@ +import { Node, SyntaxKind } from "ts-morph"; +import type { Node as MorphNode } from "ts-morph"; +import type { DebtIssue, Detector, DetectorContext } from "../core/types.js"; +import { createIssue } from "../utils/createIssue.js"; +import { nodeLineSpan } from "../utils/lines.js"; + +export const featureFlagDebtDetector: Detector = { + id: "stale-feature-flag", + name: "Stale feature flag", + description: "Flags feature flags that appear permanently enabled/disabled or unused.", + defaultSeverity: "medium", + tags: ["feature-flags", "cleanup", "maintainability"], + detect(context: DetectorContext): DebtIssue[] { + const patterns = ["flag", "feature", "enable", "enabled", "toggle"]; + const issues: DebtIssue[] = []; + + for (const file of context.files) { + const flagConstants = new Map(); + const flagUses = new Set(); + + for (const declaration of file.sourceFile.getVariableDeclarations()) { + if (!isTopLevelVariable(declaration)) continue; + const initializer = declaration.getInitializer(); + if (!initializer) continue; + const literalValue = readBooleanLiteral(initializer); + if (literalValue === undefined) continue; + const name = declaration.getName(); + if (!looksLikeFlagName(name, patterns)) continue; + const span = nodeLineSpan(declaration); + flagConstants.set(name, { file: file.relativePath, line: span.startLine, value: literalValue, name }); + } + + for (const identifier of file.sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) { + const text = identifier.getText(); + if (!flagConstants.has(text)) continue; + const parent = identifier.getParent(); + if (Node.isVariableDeclaration(parent) && parent.getName() === text) { + continue; + } + flagUses.add(text); + } + + for (const [name, info] of flagConstants.entries()) { + if (!flagUses.has(name)) { + issues.push(createIssue({ + detector: featureFlagDebtDetector, + confidence: 0.84, + file: info.file, + location: { startLine: info.line, endLine: info.line }, + message: `Feature flag ${name} is defined but never referenced.`, + evidence: [`Definition: ${name}`], + suggestion: "Remove the unused flag definition or wire it into the rollout path it was meant to guard.", + })); + continue; + } + issues.push(createIssue({ + detector: featureFlagDebtDetector, + confidence: 0.78, + file: info.file, + location: { startLine: info.line, endLine: info.line }, + message: `Feature flag ${name} is hardcoded to ${info.value}.`, + evidence: [`Literal value: ${String(info.value)}`], + suggestion: "Remove the flag and dead branch once rollout is complete, or source the value from configuration.", + })); + } + } + + return issues; + }, +}; + +function looksLikeFlagName(name: string, patterns: string[]): boolean { + const lower = name.toLowerCase(); + return patterns.some((pattern) => lower.includes(pattern)); +} + +function readBooleanLiteral(node: MorphNode): boolean | undefined { + if (Node.isTrueLiteral(node)) return true; + if (Node.isFalseLiteral(node)) return false; + return undefined; +} + +function isTopLevelVariable(declaration: MorphNode): boolean { + if (!Node.isVariableDeclaration(declaration)) return false; + return declaration.getVariableStatement()?.getParent() === declaration.getSourceFile(); +} diff --git a/src/detectors/index.ts b/src/detectors/index.ts index e677f34..d3414a5 100644 --- a/src/detectors/index.ts +++ b/src/detectors/index.ts @@ -11,6 +11,7 @@ import { dataLoaderSprawlDetector } from "./dataLoaderSprawl.js"; import { commentedOutCodeDetector } from "./commentedOutCode.js"; import { deadAbstractionDetector } from "./deadAbstraction.js"; import { emptyCatchDetector, swallowedErrorDetector } from "./errorHandling.js"; +import { featureFlagDebtDetector } from "./featureFlagDebt.js"; import { floatingPromiseDetector } from "./floatingPromise.js"; import { duplicateLogicDetector } from "./duplicateLogic.js"; import { duplicatedLiteralDetector } from "./duplicatedLiteral.js"; @@ -123,6 +124,7 @@ export const allDetectors: Detector[] = [ composeStateHoistingDetector, instructionDuplicationDetector, instructionContradictionDetector, + featureFlagDebtDetector, ]; export const detectorIds = allDetectors.map((detector) => detector.id); diff --git a/src/reporters/graphReporter.ts b/src/reporters/graphReporter.ts index 9fe4938..29ddf9c 100644 --- a/src/reporters/graphReporter.ts +++ b/src/reporters/graphReporter.ts @@ -14,7 +14,7 @@ export function renderImportGraphSvg(graph: ImportGraph, width = 640, height = 3 const nodes = graph.nodes.map((node) => { const point = positions.get(node); if (!point) return ""; - const label = truncate(node); + const label = escapeXml(truncate(node)); return `${label}`; }).join("\n"); return `${edges}${nodes}`; @@ -24,12 +24,19 @@ export function renderDebtTreemapSvg(issues: DebtIssue[], width = 640, height = const byDirectory = aggregateByDirectory(issues); const entries = [...byDirectory.entries()].sort((left, right) => right[1].total - left[1].total).slice(0, 12); if (entries.length === 0) return ""; - const max = Math.max(...entries.map(([, value]) => value.total), 1); + let remainingTotal = Math.max(entries.reduce((total, [, value]) => total + value.total, 0), 1); + let remainingWidth = width; let x = 0; - const rects = entries.map(([directory, value]) => { - const w = Math.max(48, Math.round((value.total / max) * width)); - const rect = `${directory}: ${value.total} issues${truncate(directory, 18)} (${value.total})`; + const rects = entries.map(([directory, value], index) => { + const isLast = index === entries.length - 1; + const proportional = Math.round((value.total / remainingTotal) * remainingWidth); + const w = isLast ? remainingWidth : Math.max(24, Math.min(remainingWidth, proportional)); + const escapedDirectory = escapeXml(directory); + const escapedLabel = escapeXml(truncate(directory, 18)); + const rect = `${escapedDirectory}: ${value.total} issues${escapedLabel} (${value.total})`; x += w; + remainingWidth = Math.max(0, remainingWidth - w); + remainingTotal = Math.max(1, remainingTotal - value.total); return rect; }).join("\n"); return `${rects}`; @@ -71,3 +78,12 @@ function heatColor(high: number, total: number): string { function truncate(value: string, max = 24): string { return value.length > max ? `${value.slice(0, max - 3)}...` : value; } + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} diff --git a/src/reporters/payoffSection.ts b/src/reporters/payoffSection.ts index 8ead343..9573b1a 100644 --- a/src/reporters/payoffSection.ts +++ b/src/reporters/payoffSection.ts @@ -30,7 +30,7 @@ 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 `${issue.payoffScore?.toFixed(2)}${issue.severity}${issue.ruleName}${location}${issue.message}`; + return `${issue.payoffScore?.toFixed(2)}${escapeHtml(issue.severity)}${escapeHtml(issue.ruleName)}${escapeHtml(location)}${escapeHtml(issue.message)}`; }).join("\n"); return `

Top payoff targets

${rows}
ScoreSeverityRuleLocationMessage
`; } @@ -38,3 +38,12 @@ export function renderPayoffSectionHtml(issues: DebtIssue[], limit = 10): string export function scanHasPayoffData(result: ScanResult): boolean { return hasPayoffScores(result.issues); } + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); +} diff --git a/tests/cli/fix.test.ts b/tests/cli/fix.test.ts new file mode 100644 index 0000000..95d3675 --- /dev/null +++ b/tests/cli/fix.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it } from "node:test"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { runFix } from "../../src/cli/fix.js"; +import { defaultConfig } from "../../src/config/defaults.js"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const cliEntrypoint = join(repoRoot, "src", "cli", "index.ts"); +const localRequire = createRequire(import.meta.url); +const tsxLoader = localRequire.resolve("tsx"); + +function runCli(args: string[], cwd: string) { + return spawnSync(process.execPath, ["--import", tsxLoader, cliEntrypoint, ...args], { + cwd, + encoding: "utf8", + }); +} + +describe("debtlens fix", () => { + it("dry-runs duplicated-literal extraction without writing files", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-fix-dry-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "billing.ts"), `export const one = "payment-overdue";\n`); + writeFileSync(join(dir, "src", "notifications.ts"), `export const two = "payment-overdue";\n`); + writeFileSync(join(dir, "src", "reports.ts"), `export const three = "payment-overdue";\n`); + + const result = await runFix({ + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "low", + rules: ["duplicated-literal"], + thresholds: defaultConfig.thresholds, + }, { dryRun: true }); + + assert.ok(result.diffs.length > 0); + assert.match(result.diffs.join("\n"), /SHARED_PAYMENT_OVERDUE/); + assert.match(readFileSync(join(dir, "src", "billing.ts"), "utf8"), /"payment-overdue"/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("inlines a pass-through dead-abstraction wrapper when --fix is set", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-fix-dead-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "copy.ts"), ` +function buildCopy(locale) { + return locale.toUpperCase(); +} +function getCopy(locale) { + return buildCopy(locale); +} +export const label = getCopy("en"); +`); + const result = await runFix({ + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "low", + rules: ["dead-abstraction"], + thresholds: defaultConfig.thresholds, + }, { rules: ["dead-abstraction"], dryRun: false }); + + assert.ok(result.filesTouched >= 1); + const updated = readFileSync(join(dir, "src", "copy.ts"), "utf8"); + assert.doesNotMatch(updated, /function getCopy/); + assert.match(updated, /buildCopy\("en"\)/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects non-writable rules in write mode before touching files", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-fix-reject-rule-")); + try { + mkdirSync(join(dir, "src")); + const file = join(dir, "src", "billing.ts"); + writeFileSync(file, `export const one = "payment-overdue";\n`); + await assert.rejects( + () => runFix({ + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "low", + rules: ["duplicated-literal"], + thresholds: defaultConfig.thresholds, + }, { rules: ["duplicated-literal"], dryRun: false }), + /not writable/, + ); + assert.match(readFileSync(file, "utf8"), /"payment-overdue"/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("does not remove exported pass-through wrappers in write mode", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-fix-exported-")); + try { + mkdirSync(join(dir, "src")); + const file = join(dir, "src", "copy.ts"); + writeFileSync(file, ` +export function buildCopy(locale) { + return locale.toUpperCase(); +} +export function getCopy(locale) { + return buildCopy(locale); +} +export const label = getCopy("en"); +`); + const result = await runFix({ + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "low", + rules: ["dead-abstraction"], + thresholds: defaultConfig.thresholds, + }, { rules: ["dead-abstraction"], dryRun: false }); + + assert.equal(result.filesTouched, 0); + assert.match(readFileSync(file, "utf8"), /export function getCopy/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("prints dry-run output from the CLI by default", () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-fix-cli-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "billing.ts"), `export const one = "payment-overdue";\n`); + writeFileSync(join(dir, "src", "notifications.ts"), `export const two = "payment-overdue";\n`); + writeFileSync(join(dir, "src", "reports.ts"), `export const three = "payment-overdue";\n`); + writeFileSync(join(dir, "debtlens.config.json"), JSON.stringify({ rules: ["duplicated-literal"] })); + + const result = runCli(["fix", ".", "--config", "debtlens.config.json"], dir); + assert.equal(result.status, 0); + assert.match(result.stdout, /Dry-run/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/cli/scan.test.ts b/tests/cli/scan.test.ts index d441551..07033da 100644 --- a/tests/cli/scan.test.ts +++ b/tests/cli/scan.test.ts @@ -318,6 +318,29 @@ describe("debtlens scan output formats", () => { } }); + it("writes shields endpoint JSON when badge output path ends in .json", () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-badge-json-")); + try { + const jsonPath = join(dir, "debtlens-badge.json"); + const result = runScan([ + "examples/react", + "--rules", + "todo-comment", + "--format", + "badge", + "--output", + jsonPath, + ]); + + assert.equal(result.status, 0); + const json = JSON.parse(readFileSync(jsonPath, "utf8")) as { schemaVersion: number; label: string }; + assert.equal(json.schemaVersion, 1); + assert.equal(json.label, "debt"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("fails the gate when a configured budget is breached", () => { const dir = mkdtempSync(join(tmpdir(), "debtlens-budget-")); try { diff --git a/tests/cli/triage.test.ts b/tests/cli/triage.test.ts new file mode 100644 index 0000000..7e96225 --- /dev/null +++ b/tests/cli/triage.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable, Writable } from "node:stream"; +import { describe, it } from "node:test"; +import { runTriage } from "../../src/cli/triage.js"; + +describe("debtlens triage", () => { + it("starts on a fresh repo without an existing baseline", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-triage-fresh-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "app.ts"), "// TODO triage me\nexport const value = 1;\n"); + + const counts = await runTriage({ + target: ".", + cwd: dir, + dryRun: true, + cliOptions: { rules: "todo-comment" }, + input: Readable.from(["q\n"]), + output: new Writable({ write(_chunk, _encoding, callback) { callback(); } }), + }); + + assert.deepEqual(counts, { kept: 0, baselined: 0, suppressed: 0, skipped: 0 }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/config/packs.test.ts b/tests/config/packs.test.ts index b4b0a7e..840b69e 100644 --- a/tests/config/packs.test.ts +++ b/tests/config/packs.test.ts @@ -6,7 +6,7 @@ import { getRulePack, listRulePacks } from "../../src/config/packs.js"; describe("rule packs", () => { it("lists built-in packs with expected rule counts", () => { const packs = listRulePacks(); - assert.equal(packs.length, 19); + assert.equal(packs.length, 20); assert.equal(getRulePack("core").rules.length, 20); assert.deepEqual(getRulePack("core").languages, ["tsjs"]); assert.equal(getRulePack("react").rules.length, 27); diff --git a/tests/config/schema.test.ts b/tests/config/schema.test.ts index 2f655ed..3d7540f 100644 --- a/tests/config/schema.test.ts +++ b/tests/config/schema.test.ts @@ -128,6 +128,7 @@ describe("config JSON schema", () => { "ai-assisted-maintainer", "oss-maintainer", "ai-workflow-drift", + "feature-flags", ]); assert.match(built.properties.pack?.anyOf[1]?.pattern ?? "", /compose/); assert.match(built.properties.pack?.anyOf[1]?.pattern ?? "", /svelte/); diff --git a/tests/config/validateConfig.test.ts b/tests/config/validateConfig.test.ts index 78154ba..6fefcf2 100644 --- a/tests/config/validateConfig.test.ts +++ b/tests/config/validateConfig.test.ts @@ -50,4 +50,41 @@ describe("validateConfigShape", () => { assert.match(result.errors.join("\n"), /duplicatedLiteral\.ignoreStrings must be an array of strings/); assert.match(result.errors.join("\n"), /duplicatedLiteral\.unknown is not allowed/); }); + + it("accepts budgets, badge, and payoff priority config", () => { + const result = validateConfigShape({ + budgets: { + "src/**/*.ts": { maxIssues: 10, maxHigh: 0, maxMedium: 5 }, + }, + badge: { greenMax: 5, yellowMax: 25 }, + priority: { + churn: 1.5, + age: 0.5, + severity: { high: 8, medium: 4, low: 1, info: 0 }, + }, + }); + + assert.equal(result.valid, true); + assert.deepEqual(result.errors, []); + }); + + it("rejects invalid budgets, badge, and payoff priority config", () => { + const result = validateConfigShape({ + budgets: { + src: { maxIssues: -1, extra: true }, + }, + badge: { greenMax: -1 }, + priority: { + churn: -1, + severity: { severe: 10 }, + }, + }); + + assert.equal(result.valid, false); + assert.match(result.errors.join("\n"), /budgets\.src\.maxIssues must be a non-negative integer/); + assert.match(result.errors.join("\n"), /budgets\.src\.extra is not allowed/); + assert.match(result.errors.join("\n"), /badge\.greenMax must be a non-negative integer/); + assert.match(result.errors.join("\n"), /priority\.churn must be a non-negative number/); + assert.match(result.errors.join("\n"), /priority\.severity\.severe is not allowed/); + }); }); diff --git a/tests/core/budgets.test.ts b/tests/core/budgets.test.ts index 1e50009..e06b36d 100644 --- a/tests/core/budgets.test.ts +++ b/tests/core/budgets.test.ts @@ -47,6 +47,18 @@ describe("budget evaluation", () => { assert.ok(evaluation?.breached); }); + it("matches globstars with or without an intermediate directory", () => { + const result = makeResult([ + { id: "1", ruleId: "todo-comment", ruleName: "Todo", severity: "low", confidence: 1, file: "src/a.ts", message: "todo", tags: [] }, + { id: "2", ruleId: "todo-comment", ruleName: "Todo", severity: "low", confidence: 1, file: "src/nested/b.ts", message: "todo", tags: [] }, + ]); + const evaluation = evaluateBudgets(result, { + "src/**/*.ts": { maxIssues: 1 }, + }); + assert.ok(evaluation?.breached); + assert.equal(evaluation?.areas[0]?.issueCount, 2); + }); + it("renders a budget report table", () => { const result = makeResult([]); const evaluation = evaluateBudgets(result, { diff --git a/tests/core/calibrate.test.ts b/tests/core/calibrate.test.ts index 115440e..1f9f651 100644 --- a/tests/core/calibrate.test.ts +++ b/tests/core/calibrate.test.ts @@ -43,4 +43,43 @@ describe("calibrate", () => { const report = renderCalibrateReport(calibrate); assert.match(report, /large-component\.maxLines/); }); + + it("computes non-p90 percentiles from observed samples", () => { + const result: ScanResult = { + schemaVersion: 1, + issues: [100, 200, 300].map((lines, index) => ({ + id: String(index), + ruleId: "large-component", + ruleName: "Large component", + severity: "medium" as const, + confidence: 0.8, + message: "big", + file: `src/${index}.tsx`, + tags: [], + evidence: [`Lines: ${lines} / 50`], + })), + summary: { + totalIssues: 3, + bySeverity: { high: 0, medium: 3, low: 0, info: 0 }, + byRule: { "large-component": 3 }, + filesScanned: 3, + rulesRun: 1, + elapsedMs: 1, + }, + options: { target: ".", include: [], exclude: [], minSeverity: "low" }, + }; + const options: ScanOptions = { + cwd: "/", + target: ".", + include: [], + exclude: [], + minSeverity: "low", + thresholds: { "large-component.maxLines": 50 }, + }; + + const calibrate = buildCalibrateSuggestions(result, options, { percentile: 50 }); + + assert.equal(calibrate.suggestions[0]?.observedP90, 200); + assert.equal(calibrate.suggestions[0]?.suggested, 210); + }); }); diff --git a/tests/core/parallelScan.test.ts b/tests/core/parallelScan.test.ts new file mode 100644 index 0000000..65c794d --- /dev/null +++ b/tests/core/parallelScan.test.ts @@ -0,0 +1,17 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { resolveConcurrency, shardFiles } from "../../src/core/parallelScan.js"; + +describe("parallel scan helpers", () => { + it("shards files deterministically", async () => { + const shards = await shardFiles(["a.ts", "b.ts", "c.ts", "d.ts"], 2); + assert.equal(shards.length, 2); + assert.deepEqual(shards[0], ["a.ts", "c.ts"]); + assert.deepEqual(shards[1], ["b.ts", "d.ts"]); + }); + + it("defaults concurrency to 1 unless configured", () => { + assert.equal(resolveConcurrency({ concurrency: 3 } as never), 3); + assert.equal(resolveConcurrency({} as never), 1); + }); +}); diff --git a/tests/core/scan.cache.test.ts b/tests/core/scan.cache.test.ts index 0949f94..f0d3b33 100644 --- a/tests/core/scan.cache.test.ts +++ b/tests/core/scan.cache.test.ts @@ -153,6 +153,35 @@ describe("scan cache", () => { } }); + it("supports a custom cache directory via cacheDir", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-scan-cache-dir-")); + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "app.ts"), "// TODO cache dir\nexport const value = 1;\n"); + const cacheDir = join(dir, "shared-cache"); + const options = { + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "low" as const, + rules: ["todo-comment"], + thresholds: defaultConfig.thresholds, + cache: true, + cacheDir, + }; + + const first = await scan(options); + const second = await scan(options); + + assert.equal(existsSync(join(cacheDir, "cache.json")), true); + assert.equal(first.summary.performance?.cache?.hit, false); + assert.equal(second.summary.performance?.cache?.hit, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("disables scan caching when plugin detectors are loaded", async () => { const dir = mkdtempSync(join(tmpdir(), "debtlens-scan-plugin-cache-")); try { diff --git a/tests/core/scan.parallel.test.ts b/tests/core/scan.parallel.test.ts index e43f8cf..f37c05c 100644 --- a/tests/core/scan.parallel.test.ts +++ b/tests/core/scan.parallel.test.ts @@ -31,6 +31,69 @@ describe("scan parallel dispatch", () => { assert.equal(parallel.summary.performance?.parallel, true); }); + it("keeps concurrency-based dispatch equivalent to serial dispatch", async () => { + const cwd = process.cwd(); + const baseOptions = { + cwd, + target: resolve("examples/react"), + include: defaultConfig.include, + exclude: [], + minSeverity: "medium" as const, + rules: ["duplicate-logic", "prop-drilling"], + thresholds: {}, + maxFiles: defaultConfig.maxFiles, + }; + + const serial = await scan(baseOptions); + const parallel = await scan({ ...baseOptions, concurrency: 2 }); + + assert.deepEqual( + parallel.issues.map((issue) => [issue.ruleId, issue.file, issue.location?.startLine]), + serial.issues.map((issue) => [issue.ruleId, issue.file, issue.location?.startLine]), + ); + assert.equal(parallel.summary.performance?.parallel, true); + }); + + it("caps concurrent detector dispatch when concurrency is set", async () => { + const dir = mkdtempSync(join(tmpdir(), "debtlens-scan-concurrency-cap-")); + let active = 0; + let maxActive = 0; + try { + mkdirSync(join(dir, "src")); + writeFileSync(join(dir, "src", "app.ts"), "export const value = 1;\n"); + const detectors: Detector[] = [1, 2, 3, 4].map((index) => ({ + id: `plugin-cap-${index}`, + name: `plugin-cap-${index}`, + description: "test", + defaultSeverity: "low", + tags: ["test"], + async detect() { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 20)); + active -= 1; + return []; + }, + })); + + await scan({ + cwd: dir, + target: dir, + include: defaultConfig.include, + exclude: defaultConfig.exclude, + minSeverity: "low", + rules: detectors.map((detector) => detector.id), + thresholds: defaultConfig.thresholds, + concurrency: 2, + pluginDetectors: detectors, + }); + + assert.equal(maxActive, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("merges async parallel detector warnings in detector order", async () => { const dir = mkdtempSync(join(tmpdir(), "debtlens-scan-parallel-warnings-")); try { diff --git a/tests/core/scanResultSchema.test.ts b/tests/core/scanResultSchema.test.ts index 5235a7c..4965d72 100644 --- a/tests/core/scanResultSchema.test.ts +++ b/tests/core/scanResultSchema.test.ts @@ -15,7 +15,7 @@ describe("ScanResult JSON schema", () => { required: string[]; properties: { schemaVersion: { const: number }; - issues: { items: { required: string[] } }; + issues: { items: { required: string[]; properties: Record } }; suppressions: { items: { required: string[] } }; suppressionDirectives: { items: { required: string[] } }; summary: { @@ -23,6 +23,7 @@ describe("ScanResult JSON schema", () => { deltaFromBaseline?: { required: string[] }; correlations?: { items: { required: string[] } }; duplicateClusters?: { items: { required: string[] } }; + importGraph?: { required: string[]; properties: { edges: { items: { required: string[] } } } }; hotspots?: { required: string[]; properties: { @@ -45,12 +46,15 @@ describe("ScanResult JSON schema", () => { assert.deepEqual(schema.required, ["schemaVersion", "issues", "summary", "options"]); assert.equal(schema.properties.schemaVersion.const, 1); assert.ok(schema.properties.issues.items.required.includes("fingerprint")); + assert.ok("payoffScore" in schema.properties.issues.items.properties); assert.ok(schema.properties.suppressions.items.required.includes("reason")); assert.ok(schema.properties.suppressionDirectives.items.required.includes("recommendedAction")); assert.ok(schema.properties.suppressionDirectives.items.required.includes("suppressedIssueCount")); assert.ok(schema.properties.summary.properties.deltaFromBaseline?.required.includes("totalDelta")); assert.ok(schema.properties.summary.properties.correlations?.items.required.includes("rules")); assert.ok(schema.properties.summary.properties.duplicateClusters?.items.required.includes("locations")); + assert.ok(schema.properties.summary.properties.importGraph?.required.includes("edges")); + assert.ok(schema.properties.summary.properties.importGraph?.properties.edges.items.required.includes("inCycle")); assert.ok(schema.properties.summary.properties.hotspots?.required.includes("ranking")); assert.ok(schema.properties.summary.properties.hotspots?.properties.ranking.items.required.includes("churn")); assert.ok(schema.properties.summary.properties.hotspots?.properties.ranking.items.properties.churn.required.includes("changedLines")); diff --git a/tests/detectors/featureFlagDebt.test.ts b/tests/detectors/featureFlagDebt.test.ts new file mode 100644 index 0000000..15be93a --- /dev/null +++ b/tests/detectors/featureFlagDebt.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { featureFlagDebtDetector } from "../../src/detectors/featureFlagDebt.js"; +import { runDetector } from "../helpers/runDetector.js"; + +describe("stale-feature-flag detector", () => { + it("flags hardcoded boolean feature flags", async () => { + const src = ` +const enableNewCheckout = true; +export function render() { + return enableNewCheckout ? "new" : "old"; +} +`; + const issues = await runDetector(featureFlagDebtDetector, { "flags.ts": src }); + assert.equal(issues.length, 1); + assert.equal(issues[0]?.ruleId, "stale-feature-flag"); + }); + + it("flags unused boolean feature flags", async () => { + const src = ` +const enableBetaFeature = true; +export const version = 1; +`; + const issues = await runDetector(featureFlagDebtDetector, { "flags.ts": src }); + assert.equal(issues.length, 1); + assert.match(issues[0]?.message ?? "", /never referenced/); + }); + + it("tracks same-named flags per file", async () => { + const issues = await runDetector(featureFlagDebtDetector, { + "one.ts": "const enableCheckout = true;\nexport const one = enableCheckout;\n", + "two.ts": "const enableCheckout = true;\nexport const two = enableCheckout;\n", + }); + + assert.equal(issues.length, 2); + assert.deepEqual(issues.map((issue) => issue.file).sort(), ["one.ts", "two.ts"]); + }); + + it("ignores local boolean helpers that only look like flags", async () => { + const src = ` +export function render(enabled) { + const enableButton = true; + return enabled && enableButton; +} +`; + const issues = await runDetector(featureFlagDebtDetector, { "local.ts": src }); + assert.equal(issues.length, 0); + }); +}); diff --git a/tests/reporters/graphReporter.test.ts b/tests/reporters/graphReporter.test.ts index 3401126..ea0e57d 100644 --- a/tests/reporters/graphReporter.test.ts +++ b/tests/reporters/graphReporter.test.ts @@ -18,4 +18,21 @@ describe("graph reporter", () => { ]); assert.match(treemap, / { + const graph: ImportGraph = { + nodes: ["src/&file.ts", "b.ts"], + edges: [{ from: "src/&file.ts", to: "b.ts", inCycle: false }], + cycles: [], + }; + const svg = renderImportGraphSvg(graph); + assert.match(svg, /src\/<owner>&file\.ts/); + assert.doesNotMatch(svg, //); + + const treemap = renderDebtTreemapSvg([ + { id: "1", ruleId: "todo-comment", ruleName: "Todo", severity: "low", confidence: 1, message: "todo", file: "src//a.ts", tags: [] }, + ]); + assert.match(treemap, /src\/<bad&>/); + assert.doesNotMatch(treemap, //); + }); }); diff --git a/tests/reporters/htmlReporter.test.ts b/tests/reporters/htmlReporter.test.ts index 840ba49..bc14569 100644 --- a/tests/reporters/htmlReporter.test.ts +++ b/tests/reporters/htmlReporter.test.ts @@ -15,6 +15,7 @@ describe("html reporter", () => { message: "Avoid ", file: "src/app.ts", location: { startLine: 2 }, + payoffScore: 12.5, tags: [], }])); @@ -22,7 +23,9 @@ describe("html reporter", () => { assert.match(html, /DebtLens Report/); assert.match(html, /Todo <comment>/); assert.doesNotMatch(html, /