From a2e694e9c067b294a661680f40f84a460fa8426b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Jun 2026 01:42:41 +0000 Subject: [PATCH] feat: add ownership scorecards and HTML graph treemap (#253, #264) - Add ownership report with leaderboards and --ownership-report CLI flag - Extract reusable import graph builder and attach to scan summaries - Render import graph and debt treemap in HTML reports Co-authored-by: ColumbusLabs --- CHANGELOG.md | 2 + src/cli/commands/scan.ts | 25 ++++++ src/core/importGraph.ts | 103 ++++++++++++++++++++++ src/core/ownershipReport.ts | 119 ++++++++++++++++++++++++++ src/core/scan.ts | 3 + src/core/types.ts | 2 + src/detectors/importCycle.ts | 81 +----------------- src/reporters/graphReporter.ts | 73 ++++++++++++++++ src/reporters/htmlReporter.ts | 7 ++ tests/core/importGraph.test.ts | 31 +++++++ tests/reporters/graphReporter.test.ts | 21 +++++ 11 files changed, 390 insertions(+), 77 deletions(-) create mode 100644 src/core/importGraph.ts create mode 100644 src/core/ownershipReport.ts create mode 100644 src/reporters/graphReporter.ts create mode 100644 tests/core/importGraph.test.ts create mode 100644 tests/reporters/graphReporter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b0822a1..920325e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/commands/scan.ts b/src/cli/commands/scan.ts index 139d50c..d262065 100644 --- a/src/cli/commands/scan.ts +++ b/src/cli/commands/scan.ts @@ -7,6 +7,7 @@ import { RULE_PACK_IDS } from "../../config/packs.js"; import { resolveWorkspacePackage } from "../../config/workspaces.js"; import { DEFAULT_BASELINE_FILENAME, createBaseline, writeBaseline } from "../../core/baseline.js"; import { evaluateBudgets, renderBudgetReport } from "../../core/budgets.js"; +import { 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"; @@ -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 ", "with --hotspots, look back this many days", parseInteger) .option("--churn-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 ", "with --ownership-report, filter to one owner team") .option("--ownership", "attach CODEOWNERS-based ownership summaries to reports") .option("--codeowners ", "with --ownership, read ownership rules from this CODEOWNERS file") .option("--group-by ", "terminal grouping: severity, rule, or file", "severity") @@ -270,6 +273,28 @@ export async function runScanCommand(target: string, rawOptions: Record(); + 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> { + const byRelative = new Map(files.map((file) => [file.relativePath, file])); + const graph = new Map>(); + 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 | 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[][] { + const cycles: string[][] = []; + const visit = (start: string, current: string, path: string[], seen: Set) => { + 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; +} diff --git a/src/core/ownershipReport.ts b/src/core/ownershipReport.ts new file mode 100644 index 0000000..78712df --- /dev/null +++ b/src/core/ownershipReport.ts @@ -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; + 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; +}): 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; + 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 "→"; +} diff --git a/src/core/scan.ts b/src/core/scan.ts index d0c4aff..28eb851 100644 --- a/src/core/scan.ts +++ b/src/core/scan.ts @@ -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"; @@ -128,6 +129,7 @@ export async function scan(options: ScanOptions): Promise { 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, @@ -146,6 +148,7 @@ export async function scan(options: ScanOptions): Promise { ...(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: { diff --git a/src/core/types.ts b/src/core/types.ts index aa4c4e2..d14603f 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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"; @@ -504,6 +505,7 @@ export interface ScanSummary { ownership?: ScanOwnershipSummary; profile?: ScanProfile; performance?: ScanPerformance; + importGraph?: ImportGraph; } export interface ScanResult { diff --git a/src/detectors/importCycle.ts b/src/detectors/importCycle.ts index 4fbfc86..39856da 100644 --- a/src/detectors/importCycle.ts +++ b/src/detectors/importCycle.ts @@ -1,5 +1,5 @@ -import { dirname, extname, join, normalize } from "node:path"; -import type { DebtIssue, Detector, DetectorContext, SourceFileInfo } from "../core/types.js"; +import type { DebtIssue, Detector, DetectorContext } from "../core/types.js"; +import { buildImportGraphFromFiles } from "../core/importGraph.js"; import { createIssue } from "../utils/createIssue.js"; export const importCycleDetector: Detector = { @@ -11,12 +11,11 @@ export const importCycleDetector: Detector = { detect(context: DetectorContext): DebtIssue[] { const minCycleSize = context.getThreshold("import-cycle.minCycleSize", 2); const allowTypeOnly = context.getThreshold("import-cycle.allowTypeOnly", 1) >= 1; - const graph = buildImportGraph(context.files, allowTypeOnly); - const cycles = findCycles(graph).filter((cycle) => cycleSize(cycle) >= minCycleSize); + const graph = buildImportGraphFromFiles(context.files, allowTypeOnly); const issues: DebtIssue[] = []; const seen = new Set(); - for (const cycle of cycles) { + for (const cycle of graph.cycles.filter((candidate) => cycleSize(candidate) >= minCycleSize)) { const key = canonicalCycleKey(cycle); if (seen.has(key)) continue; seen.add(key); @@ -39,78 +38,6 @@ export const importCycleDetector: Detector = { }, }; -function buildImportGraph(files: SourceFileInfo[], allowTypeOnly: boolean): Map> { - const byRelative = new Map(files.map((file) => [file.relativePath, file])); - const graph = new Map>(); - 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 | 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[][] { - const cycles: string[][] = []; - const visit = (start: string, current: string, path: string[], seen: Set) => { - 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; -} - function canonicalCycleKey(cycleWithRepeat: string[]): string { const cycle = cycleWithRepeat.slice(0, -1); const rotations = cycle.map((_, index) => [...cycle.slice(index), ...cycle.slice(0, index)].join("|")); diff --git a/src/reporters/graphReporter.ts b/src/reporters/graphReporter.ts new file mode 100644 index 0000000..9fe4938 --- /dev/null +++ b/src/reporters/graphReporter.ts @@ -0,0 +1,73 @@ +import type { ImportGraph } from "../core/importGraph.js"; +import type { DebtIssue } from "../core/types.js"; + +export function renderImportGraphSvg(graph: ImportGraph, width = 640, height = 360): string { + if (graph.nodes.length === 0) return ""; + const positions = layoutNodes(graph.nodes, width, height); + const edges = graph.edges.map((edge) => { + const from = positions.get(edge.from); + const to = positions.get(edge.to); + if (!from || !to) return ""; + const stroke = edge.inCycle ? "#e05d44" : "#8c959f"; + return ``; + }).join("\n"); + const nodes = graph.nodes.map((node) => { + const point = positions.get(node); + if (!point) return ""; + const label = truncate(node); + return `${label}`; + }).join("\n"); + return `${edges}${nodes}`; +} + +export function renderDebtTreemapSvg(issues: DebtIssue[], width = 640, height = 240): string { + 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 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})`; + x += w; + return rect; + }).join("\n"); + return `${rects}`; +} + +function layoutNodes(nodes: string[], width: number, height: number): Map { + const positions = new Map(); + const radius = Math.min(width, height) / 2 - 30; + const centerX = width / 2; + const centerY = height / 2; + nodes.forEach((node, index) => { + const angle = (index / Math.max(nodes.length, 1)) * Math.PI * 2; + positions.set(node, { + x: Math.round(centerX + Math.cos(angle) * radius), + y: Math.round(centerY + Math.sin(angle) * radius), + }); + }); + return positions; +} + +function aggregateByDirectory(issues: DebtIssue[]): Map { + const counts = new Map(); + for (const issue of issues) { + const directory = issue.file.includes("/") ? issue.file.split("/").slice(0, 2).join("/") : issue.file; + const current = counts.get(directory) ?? { total: 0, high: 0 }; + current.total += 1; + if (issue.severity === "high") current.high += 1; + counts.set(directory, current); + } + return counts; +} + +function heatColor(high: number, total: number): string { + if (high > 0) return "#f9c6bd"; + if (total >= 8) return "#f6e6b4"; + return "#d9f0d1"; +} + +function truncate(value: string, max = 24): string { + return value.length > max ? `${value.slice(0, max - 3)}...` : value; +} diff --git a/src/reporters/htmlReporter.ts b/src/reporters/htmlReporter.ts index 28fe9eb..e6dc7fd 100644 --- a/src/reporters/htmlReporter.ts +++ b/src/reporters/htmlReporter.ts @@ -1,5 +1,6 @@ import { buildDebtHeatmap, buildFixTargets } from "../core/issueAggregates.js"; import type { ScanResult, Severity } from "../core/types.js"; +import { renderDebtTreemapSvg, renderImportGraphSvg } from "./graphReporter.js"; import { renderPayoffSectionHtml } from "./payoffSection.js"; import { formatFilterStats } from "./filterStats.js"; import { @@ -30,6 +31,10 @@ export function renderHtml(result: ScanResult): string { )).join("\n"); const suppressionAudit = renderSuppressionAudit(result); const payoffSection = renderPayoffSectionHtml(result.issues); + const importGraphSection = result.summary.importGraph + ? `

Import graph

${renderImportGraphSvg(result.summary.importGraph)}` + : ""; + const treemapSection = `

Debt treemap

${renderDebtTreemapSvg(result.issues)}`; return ` @@ -106,6 +111,8 @@ ${heatmap.map((entry) => `${escapeHtml(entry.file)} ` : ""} ${payoffSection} + ${importGraphSection} + ${treemapSection} ${correlations ? `

Rule Correlations

diff --git a/tests/core/importGraph.test.ts b/tests/core/importGraph.test.ts new file mode 100644 index 0000000..28628d7 --- /dev/null +++ b/tests/core/importGraph.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildImportGraphFromFiles } from "../../src/core/importGraph.js"; +import { parseSourceFile } from "../../src/core/languages.js"; +import { Project, ScriptTarget, ts } from "ts-morph"; + +describe("import graph", () => { + it("marks cycle edges", () => { + const project = new Project({ + useInMemoryFileSystem: true, + compilerOptions: { allowJs: true, target: ScriptTarget.ES2022 }, + }); + const a = parseSourceFile({ + project, + absolutePath: "/a.ts", + relativePath: "a.ts", + content: `import "./b.ts";\nexport const a = 1;`, + language: "tsjs", + }); + const b = parseSourceFile({ + project, + absolutePath: "/b.ts", + relativePath: "b.ts", + content: `import "./a.ts";\nexport const b = 1;`, + language: "tsjs", + }); + const graph = buildImportGraphFromFiles([a, b], true); + assert.ok(graph.cycles.length > 0); + assert.ok(graph.edges.some((edge) => edge.inCycle)); + }); +}); diff --git a/tests/reporters/graphReporter.test.ts b/tests/reporters/graphReporter.test.ts new file mode 100644 index 0000000..3401126 --- /dev/null +++ b/tests/reporters/graphReporter.test.ts @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderDebtTreemapSvg, renderImportGraphSvg } from "../../src/reporters/graphReporter.js"; +import type { ImportGraph } from "../../src/core/importGraph.js"; + +describe("graph reporter", () => { + it("renders self-contained svg fragments", () => { + const graph: ImportGraph = { + nodes: ["a.ts", "b.ts"], + edges: [{ from: "a.ts", to: "b.ts", inCycle: false }], + cycles: [], + }; + const svg = renderImportGraphSvg(graph); + assert.match(svg, /
FileIssuesRules