diff --git a/README.md b/README.md index 0efffd4..46b5275 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ Options: --cwd working directory --no-color disable terminal color -q, --quiet terminal only: suppress per-finding detail +--profile print per-rule timing to stderr without changing findings ``` Examples: diff --git a/src/cli/index.ts b/src/cli/index.ts index 385a5e9..e5848d5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -49,6 +49,7 @@ program.command("scan") .option("--cwd ", "working directory", process.cwd()) .option("--no-color", "disable ANSI color in terminal output") .option("-q, --quiet", "print only the summary line, suppress individual findings") + .option("--profile", "print per-rule timing without changing findings") .action(async (target: string, rawOptions: Record) => { try { const format = parseFormat(String(rawOptions.format ?? "terminal")); @@ -94,6 +95,7 @@ program.command("scan") respectGitignore: rawOptions.respectGitignore === true ? true : undefined, changedFiles, fileContents, + profile: rawOptions.profile === true, }); if (rawOptions.writeBaseline && rawOptions.baseline) { @@ -115,6 +117,10 @@ program.command("scan") } } + if (rawOptions.profile === true && result.summary.profile) { + process.stderr.write(formatProfileReport(result.summary.profile.ruleTimingsMs)); + } + if (rawOptions.writeBaseline) { const baselinePath = rawOptions.writeBaseline === true ? DEFAULT_BASELINE_FILENAME @@ -433,3 +439,11 @@ async function resolveReportedIssues( }); return applyBaseline(result, createBaseline(baseResult.issues)); } + +function formatProfileReport(ruleTimingsMs: Record): string { + const lines = ["DebtLens profile (per-rule ms):"]; + for (const [ruleId, elapsedMs] of Object.entries(ruleTimingsMs).sort((left, right) => right[1] - left[1])) { + lines.push(` ${ruleId}: ${elapsedMs}ms`); + } + return `${lines.join("\n")}\n`; +} diff --git a/src/config/mergeConfig.ts b/src/config/mergeConfig.ts index fc3fe10..1471e69 100644 --- a/src/config/mergeConfig.ts +++ b/src/config/mergeConfig.ts @@ -51,5 +51,6 @@ export function mergeConfig(target: string, fileConfig: DebtLensConfig, cliOptio : undefined, changedFiles: cliOptions.changedFiles, fileContents: cliOptions.fileContents, + profile: cliOptions.profile, }; } diff --git a/src/core/scan.ts b/src/core/scan.ts index 3b9d281..c5c40da 100644 --- a/src/core/scan.ts +++ b/src/core/scan.ts @@ -41,8 +41,10 @@ export async function scan(options: ScanOptions): Promise { let issues: DebtIssue[] = []; const warnings: string[] = []; let filteredByMinSeverity = 0; + const ruleTimingsMs: Record = {}; for (const detector of detectors) { + const detectorStartedAt = options.profile ? Date.now() : 0; const detectorIssues = await detector.detect({ project, files, @@ -59,6 +61,9 @@ export async function scan(options: ScanOptions): Promise { filteredByMinSeverity += 1; } } + if (options.profile) { + ruleTimingsMs[detector.id] = Date.now() - detectorStartedAt; + } } issues.sort((a, b) => { @@ -98,6 +103,7 @@ export async function scan(options: ScanOptions): Promise { elapsedMs: Date.now() - startedAt, ...(warnings.length ? { warnings } : {}), ...(Object.keys(filterStats).length > 0 ? { filterStats } : {}), + ...(options.profile ? { profile: { ruleTimingsMs } } : {}), }; return { diff --git a/src/core/types.ts b/src/core/types.ts index b8570c1..d56c806 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -95,6 +95,8 @@ export interface ScanOptions { todoCommentReplaceDefaults?: boolean; todoCommentDisableDefaults?: string[]; todoCommentMarkers?: Array<{ regex: RegExp; severity: Severity; label: string }>; + /** When true, collect per-rule timing in `summary.profile`. */ + profile?: boolean; } export interface CliOptions { @@ -115,6 +117,7 @@ export interface CliOptions { noColor?: boolean; changedFiles?: string[]; fileContents?: Record; + profile?: boolean; } export interface DetectorContext { @@ -140,6 +143,10 @@ export interface ScanFilterStats { suppressedByInline?: number; } +export interface ScanProfile { + ruleTimingsMs: Record; +} + export interface ScanSummary { totalIssues: number; bySeverity: Record; @@ -149,6 +156,7 @@ export interface ScanSummary { elapsedMs: number; warnings?: string[]; filterStats?: ScanFilterStats; + profile?: ScanProfile; } export interface ScanResult { diff --git a/tests/cli/scan.test.ts b/tests/cli/scan.test.ts index 576b9a8..deb1682 100644 --- a/tests/cli/scan.test.ts +++ b/tests/cli/scan.test.ts @@ -195,6 +195,19 @@ describe("debtlens scan diff-base", () => { }); }); +describe("debtlens scan profile", () => { + it("prints per-rule timing to stderr without changing findings", () => { + const result = runScan(["examples/react", "--rules", "todo-comment", "--profile", "--format", "json"]); + const parsed = JSON.parse(result.stdout); + + assert.equal(result.status, 0); + assert.match(result.stderr, /DebtLens profile \(per-rule ms\):/); + assert.match(result.stderr, /todo-comment: \d+ms/); + assert.ok(parsed.summary.profile?.ruleTimingsMs["todo-comment"] !== undefined); + assert.equal(parsed.summary.totalIssues, parsed.issues.length); + }); +}); + describe("debtlens scan git modes", () => { it("rejects --changed and --staged together", () => { const result = runScan(["examples/react", "--changed", "--staged"]);