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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ 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.
- `--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
design smells beyond single-axis size checks.
- Config `budgets` block and `debtlens scan --budget-report` for per-area debt SLO gating.
Expand Down
25 changes: 25 additions & 0 deletions src/cli/commands/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { RULE_PACK_IDS } from "../../config/packs.js";
import { resolveWorkspacePackage } from "../../config/workspaces.js";
import { DEFAULT_BASELINE_FILENAME, createBaseline, writeBaseline } from "../../core/baseline.js";
import { evaluateBudgets, renderBudgetReport } from "../../core/budgets.js";
import { buildOwnershipReport, renderOwnershipReportTerminal } from "../../core/ownershipReport.js";
import { enrichIssuesWithPayoffScores, sortIssuesByPayoff } from "../../core/priority.js";
import { buildGitChurnHotspots } from "../../core/hotspots.js";
import { buildOwnershipSummary, loadCodeowners } from "../../core/ownership.js";
Expand Down Expand Up @@ -86,6 +87,8 @@ export function registerScanCommand(program: Command): void {
.option("--hotspots [limit]", "rank files by current findings plus recent git churn", parseOptionalInteger)
.option("--churn-days <count>", "with --hotspots, look back this many days", parseInteger)
.option("--churn-range <range>", "with --hotspots, use this git revision range instead of --churn-days")
.option("--ownership-report", "render CODEOWNERS ownership scorecards instead of a scan report")
.option("--owner <pattern>", "with --ownership-report, filter to one owner team")
.option("--ownership", "attach CODEOWNERS-based ownership summaries to reports")
.option("--codeowners <path>", "with --ownership, read ownership rules from this CODEOWNERS file")
.option("--group-by <group>", "terminal grouping: severity, rule, or file", "severity")
Expand Down Expand Up @@ -270,6 +273,28 @@ export async function runScanCommand(target: string, rawOptions: Record<string,
};
}

if (rawOptions.ownershipReport === true) {
enrichIssuesWithPayoffScores(reported.issues, {
hotspots: reported.summary.hotspots,
weights: fileConfig.priority,
});
const ownershipReport = buildOwnershipReport({
result: reported,
cwd,
codeownersPath: typeof rawOptions.codeowners === "string" ? rawOptions.codeowners : undefined,
ownerFilter: typeof rawOptions.owner === "string" ? rawOptions.owner : undefined,
});
if (!ownershipReport) {
writeStderr("DebtLens: --ownership-report ignored (CODEOWNERS not found).\n");
return { report: "", exitCode: 1, stderr: stderrChunks.join("") };
}
return {
report: renderOwnershipReportTerminal(ownershipReport),
exitCode: 0,
stderr: stderrChunks.join(""),
};
}

const badgeThresholds = parseBadgeThresholds(fileConfig.badge);

const report = renderReport(reported, format, {
Expand Down
103 changes: 103 additions & 0 deletions src/core/importGraph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { dirname, extname, join, normalize } from "node:path";
import type { SourceFileInfo } from "./types.js";

export interface ImportGraphEdge {
from: string;
to: string;
inCycle: boolean;
}

export interface ImportGraph {
nodes: string[];
edges: ImportGraphEdge[];
cycles: string[][];
}

export function buildImportGraphFromFiles(files: SourceFileInfo[], allowTypeOnly = true): ImportGraph {
const adjacency = buildAdjacency(files, allowTypeOnly);
const cycles = findCycles(adjacency);
const cycleEdges = new Set<string>();
for (const cycle of cycles) {
for (let index = 0; index < cycle.length - 1; index += 1) {
const from = cycle[index];
const to = cycle[index + 1];
if (from && to) cycleEdges.add(`${from}->${to}`);
}
}
const edges: ImportGraphEdge[] = [];
for (const [from, targets] of adjacency.entries()) {
for (const to of targets) {
edges.push({ from, to, inCycle: cycleEdges.has(`${from}->${to}`) });
}
}
return {
nodes: [...adjacency.keys()].sort((left, right) => left.localeCompare(right)),
edges: edges.sort((left, right) => `${left.from}->${left.to}`.localeCompare(`${right.from}->${right.to}`)),
cycles,
};
}

function buildAdjacency(files: SourceFileInfo[], allowTypeOnly: boolean): Map<string, Set<string>> {
const byRelative = new Map(files.map((file) => [file.relativePath, file]));
const graph = new Map<string, Set<string>>();
for (const file of files) graph.set(file.relativePath, new Set());

for (const file of files) {
const edges = graph.get(file.relativePath);
if (!edges) continue;
for (const importDeclaration of file.sourceFile.getImportDeclarations()) {
if (allowTypeOnly && importDeclaration.isTypeOnly()) continue;
const specifier = importDeclaration.getModuleSpecifierValue();
if (!specifier.startsWith(".")) continue;
const resolved = resolveRelativeModule(file.relativePath, specifier, byRelative);
if (resolved) edges.add(resolved);
}
for (const exportDeclaration of file.sourceFile.getExportDeclarations()) {
if (allowTypeOnly && exportDeclaration.isTypeOnly()) continue;
const specifier = exportDeclaration.getModuleSpecifierValue();
if (!specifier?.startsWith(".")) continue;
const resolved = resolveRelativeModule(file.relativePath, specifier, byRelative);
if (resolved) edges.add(resolved);
}
}
return graph;
}

function resolveRelativeModule(
fromRelativePath: string,
specifier: string,
files: Map<string, SourceFileInfo>,
): string | undefined {
const base = normalize(join(dirname(fromRelativePath), specifier)).replaceAll("\\", "/");
const candidates = extname(base)
? [base]
: [
`${base}.ts`,
`${base}.tsx`,
`${base}.js`,
`${base}.jsx`,
`${base}/index.ts`,
`${base}/index.tsx`,
`${base}/index.js`,
`${base}/index.jsx`,
];
return candidates.find((candidate) => files.has(candidate));
}

function findCycles(graph: Map<string, Set<string>>): string[][] {
const cycles: string[][] = [];
const visit = (start: string, current: string, path: string[], seen: Set<string>) => {
for (const next of graph.get(current) ?? []) {
if (next === start) {
cycles.push([...path, next]);
continue;
}
if (seen.has(next)) continue;
seen.add(next);
visit(start, next, [...path, next], seen);
seen.delete(next);
}
};
for (const file of graph.keys()) visit(file, file, [file], new Set([file]));
return cycles;
}
119 changes: 119 additions & 0 deletions src/core/ownershipReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { groupIssuesByRule, summarizeIssues } from "./issueAggregates.js";
import { buildOwnershipSummary, loadCodeowners } from "./ownership.js";
import { sortIssuesByPayoff } from "./priority.js";
import type { DebtIssue, ScanOwnershipSummary, ScanResult, Severity } from "./types.js";

export interface OwnershipScorecardEntry {
owner: string;
totalIssues: number;
bySeverity: Record<Severity, number>;
topRules: Array<{ ruleId: string; count: number }>;
topFiles: Array<{ file: string; totalIssues: number; score: number }>;
payoffWeightedDebt?: number;
trend?: "up" | "down" | "flat";
}

export interface OwnershipReport {
codeownersPath: string;
owners: OwnershipScorecardEntry[];
unowned: OwnershipScorecardEntry;
leaderboardByCount: OwnershipScorecardEntry[];
leaderboardByPayoff: OwnershipScorecardEntry[];
}

export function buildOwnershipReport(input: {
result: ScanResult;
cwd: string;
codeownersPath?: string;
ownerFilter?: string;
historyTotalsByOwner?: Record<string, number[]>;
}): OwnershipReport | undefined {
const codeowners = loadCodeowners(input.cwd, input.codeownersPath);
if (!codeowners) return undefined;

const ownership = buildOwnershipSummary({
issues: input.result.issues,
codeowners,
});
if (!ownership) return undefined;

const owners = ownership.ownerSummaries.map((owner) => toScorecard(owner, input.result.issues, 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;
});
const unowned = toScorecard({
owner: "unowned",
totalIssues: unownedIssues.length,
bySeverity: summarizeIssues(unownedIssues).bySeverity,
topFiles: [],
}, unownedIssues);

const filteredOwners = input.ownerFilter
? owners.filter((owner) => owner.owner.includes(input.ownerFilter!))
: owners;

return {
codeownersPath: ownership.codeownersPath,
owners: filteredOwners,
unowned,
leaderboardByCount: [...filteredOwners].sort((left, right) => right.totalIssues - left.totalIssues || left.owner.localeCompare(right.owner)),
leaderboardByPayoff: [...filteredOwners].sort((left, right) => (right.payoffWeightedDebt ?? 0) - (left.payoffWeightedDebt ?? 0) || left.owner.localeCompare(right.owner)),
};
}

function toScorecard(
owner: {
owner: string;
totalIssues: number;
bySeverity: Record<Severity, number>;
topFiles: Array<{ file: string; totalIssues: number; score: number }>;
},
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)
.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);
return {
owner: owner.owner,
totalIssues: owner.totalIssues,
bySeverity: owner.bySeverity,
topRules,
topFiles: owner.topFiles,
payoffWeightedDebt: Number(payoffWeightedDebt.toFixed(2)),
trend: trendFromHistory(history),
};
}

function trendFromHistory(history?: number[]): "up" | "down" | "flat" | undefined {
if (!history || history.length < 2) return undefined;
const previous = history[history.length - 2] ?? 0;
const current = history[history.length - 1] ?? 0;
if (current > previous) return "up";
if (current < previous) return "down";
return "flat";
}

export function renderOwnershipReportTerminal(report: OwnershipReport): string {
const lines = [
"Ownership scorecard",
`CODEOWNERS: ${report.codeownersPath}`,
"",
"Leaderboard (count)",
];
for (const owner of report.leaderboardByCount.slice(0, 10)) {
lines.push(` ${owner.owner}: ${owner.totalIssues} issues (${owner.bySeverity.high} high)${owner.trend ? ` ${arrow(owner.trend)}` : ""}`);
}
lines.push("", `Unowned bucket: ${report.unowned.totalIssues} issues`);
return `${lines.join("\n")}\n`;
}

function arrow(trend: "up" | "down" | "flat"): string {
if (trend === "up") return "↑";
if (trend === "down") return "↓";
return "→";
}
3 changes: 3 additions & 0 deletions src/core/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { basename, relative } from "node:path";
import { Project, ScriptTarget, ts } from "ts-morph";
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 { canonicalize, resolveFileSelection } from "./resolveFiles.js";
import { buildScanCacheKey, getScanCachePath, hashContent, readCachedScan, writeCachedScan, type FileSnapshot } from "./scanCache.js";
Expand Down Expand Up @@ -128,6 +129,7 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
const issueSummary = summarizeIssues(issues);
const correlations = buildRuleCorrelations(issues);
const duplicateClusters = buildDuplicateLogicClusters(issues);
const importGraph = buildImportGraphFromFiles(files.filter((file) => file.language === "tsjs"), true);
const result: ScanResult = {
schemaVersion: 1,
issues,
Expand All @@ -146,6 +148,7 @@ export async function scan(options: ScanOptions): Promise<ScanResult> {
...(Object.keys(filterStats).length > 0 ? { filterStats } : {}),
...(correlations.length > 0 ? { correlations } : {}),
...(duplicateClusters.length > 0 ? { duplicateClusters } : {}),
importGraph,
...(options.profile ? { profile: { ruleTimingsMs } } : {}),
...(cachePath || options.batchSize || options.parallel ? {
performance: {
Expand Down
2 changes: 2 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Project, SourceFile } from "ts-morph";
import type { ImportGraph } from "./importGraph.js";

export type Severity = "info" | "low" | "medium" | "high";
export type OutputFormat = "terminal" | "json" | "markdown" | "pr-comment" | "sarif" | "html" | "junit" | "gitlab-codequality" | "badge";
Expand Down Expand Up @@ -504,6 +505,7 @@ export interface ScanSummary {
ownership?: ScanOwnershipSummary;
profile?: ScanProfile;
performance?: ScanPerformance;
importGraph?: ImportGraph;
}

export interface ScanResult {
Expand Down
Loading