diff --git a/src/cli/output/ink-runner.test.tsx b/src/cli/output/ink-runner.test.tsx index 06579eed..f6583ec5 100644 --- a/src/cli/output/ink-runner.test.tsx +++ b/src/cli/output/ink-runner.test.tsx @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import figures from 'figures'; import { Verbosity } from './verbosity.js'; import { getSkillCostUSD, runSkillTasksWithInk } from './ink-runner.js'; @@ -217,6 +218,61 @@ describe('runSkillTasksWithInk', () => { expect(controller.signal.aborted).toBe(false); }); + it('prints completed file counts from final findings, not rejected candidates', async () => { + const stderrWrite = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + mockRunComposedSkillTasks.mockImplementationOnce(async (_tasks, callbacks) => { + callbacks.onSkillStart({ + name: 'find-warden-bugs', + displayName: 'find-warden-bugs', + status: 'running', + files: [{ + filename: 'src/app.ts', + status: 'running', + currentHunk: 1, + totalHunks: 1, + findings: [], + }], + findings: [], + }); + callbacks.onFileUpdate('find-warden-bugs', 'src/app.ts', { + status: 'done', + currentHunk: 1, + totalHunks: 1, + findings: [{ + id: 'candidate', + severity: 'high', + title: 'Rejected candidate', + description: 'Rejected during verification', + location: { path: 'src/app.ts', startLine: 10 }, + }], + }); + callbacks.onSkillComplete('find-warden-bugs', { + skill: 'find-warden-bugs', + summary: 'find-warden-bugs: No issues found', + findings: [], + durationMs: 1_200, + }); + return []; + }); + + await runSkillTasksWithInk( + [{ + name: 'find-warden-bugs', + displayName: 'find-warden-bugs', + } as never], + { + mode: { isTTY: true, supportsColor: false, columns: 80 }, + verbosity: Verbosity.Normal, + concurrency: 2, + }, + ); + + const output = stderrWrite.mock.calls.map(([chunk]) => String(chunk)).join(''); + expect(output).toContain('src/app.ts'); + expect(output).not.toContain(`${figures.bullet} 1`); + }); + it('does not trigger fail-fast from file findings in quiet mode', async () => { const controller = new AbortController(); diff --git a/src/cli/output/ink-runner.tsx b/src/cli/output/ink-runner.tsx index 5b2cb9af..eacae891 100644 --- a/src/cli/output/ink-runner.tsx +++ b/src/cli/output/ink-runner.tsx @@ -30,8 +30,9 @@ import { Semaphore } from '../../utils/index.js'; import { Verbosity } from './verbosity.js'; import { ICON_CHECK, ICON_SKIPPED, ICON_PENDING, ICON_ERROR, SPINNER_FRAMES } from './icons.js'; import figures from 'figures'; -import type { SkillReport } from '../../types/index.js'; +import type { Finding, SkillReport } from '../../types/index.js'; import { ProviderFailureCircuitBreaker } from '../../sdk/circuit-breaker.js'; +import { findingAppliesToFile } from '../../sdk/report-files.js'; interface SkillRunnerProps { skills: SkillState[]; @@ -201,6 +202,13 @@ const noopCallbacks: SkillProgressCallbacks = { onSkillError: noop, }; +function syncFileFindingsWithFinalReport(files: FileState[], findings: Finding[]): FileState[] { + return files.map((file) => ({ + ...file, + findings: findings.filter((finding) => findingAppliesToFile(finding, file.filename)), + })); +} + /** Severity levels in display order. */ const SEVERITY_LEVELS = ['high', 'medium', 'low'] as const; @@ -368,7 +376,11 @@ export async function runSkillTasksWithInk( const idx = skillStates.findIndex((s) => s.name === name); const existing = skillStates[idx]; if (idx >= 0 && existing) { - skillStates[idx] = { ...existing, ...updates }; + const next: SkillState = { ...existing, ...updates }; + if (updates.findings !== undefined) { + next.files = syncFileFindingsWithFinalReport(next.files, updates.findings); + } + skillStates[idx] = next; updateUI(); } }, @@ -394,6 +406,7 @@ export async function runSkillTasksWithInk( findings: report.findings, usage: report.usage, auxiliaryUsage: report.auxiliaryUsage, + files: syncFileFindingsWithFinalReport(existing.files, report.findings), }; } if (failFastController && report.findings.length > 0) { diff --git a/src/cli/output/tasks.test.ts b/src/cli/output/tasks.test.ts index b5b54424..3b836d16 100644 --- a/src/cli/output/tasks.test.ts +++ b/src/cli/output/tasks.test.ts @@ -728,6 +728,7 @@ describe('runSkillTasks', () => { }); expect(results[0]?.report?.findings).toEqual([]); + expect(results[0]?.report?.files?.[0]?.findings).toBe(0); expect(controller.signal.aborted).toBe(false); expect(postProcessFindings).toHaveBeenCalled(); diff --git a/src/cli/output/tasks.ts b/src/cli/output/tasks.ts index 86d1dc1a..5966d411 100644 --- a/src/cli/output/tasks.ts +++ b/src/cli/output/tasks.ts @@ -25,6 +25,7 @@ import { type FindingProcessingEvent, } from '../../sdk/runner.js'; import { ProviderFailureCircuitBreaker } from '../../sdk/circuit-breaker.js'; +import { buildFileReports } from '../../sdk/report-files.js'; import chalk from 'chalk'; import figures from 'figures'; import { Verbosity } from './verbosity.js'; @@ -596,15 +597,17 @@ export async function runSkillTask( usage: aggregateUsage(allUsage), durationMs: duration, model: runnerOptions?.model, - files: preparedFiles.map((file, i) => { - const r = allResults[i]; - return { - filename: file.filename, - findings: r?.findings.length ?? 0, - durationMs: r?.durationMs, - usage: r?.usage, - }; - }), + files: buildFileReports( + preparedFiles.map((file, i) => { + const r = allResults[i]; + return { + filename: file.filename, + durationMs: r?.durationMs, + usage: r?.usage, + }; + }), + finalFindings, + ), }; if (skippedFiles.length > 0) { report.skippedFiles = skippedFiles; diff --git a/src/sdk/analyze-verification.test.ts b/src/sdk/analyze-verification.test.ts index d72d81c1..da02c069 100644 --- a/src/sdk/analyze-verification.test.ts +++ b/src/sdk/analyze-verification.test.ts @@ -91,6 +91,7 @@ describe('runSkill verification', () => { }); expect(report.findings).toEqual([]); + expect(report.files?.[0]?.findings).toBe(0); expect(report.auxiliaryUsage?.['verification']).toEqual(makeUsage()); expect(verifyFindings).toHaveBeenCalledWith( expect.any(Array), diff --git a/src/sdk/analyze.ts b/src/sdk/analyze.ts index 1f86a1bf..daf72ed5 100644 --- a/src/sdk/analyze.ts +++ b/src/sdk/analyze.ts @@ -9,6 +9,7 @@ import { aggregateUsage, emptyUsage, estimateTokens, aggregateAuxiliaryUsage } f import { buildHunkSystemPrompt, buildHunkUserPrompt, type PRPromptContext } from './prompt.js'; import { extractFindingsJson, extractFindingsWithLLM, validateFindings } from './extract.js'; import { postProcessFindings } from './post-process.js'; +import { buildFileReports } from './report-files.js'; import { getRuntime, getRuntimeProviderOptions } from './runtimes/index.js'; import type { SkillRunResult } from './runtimes/index.js'; import { @@ -893,12 +894,14 @@ export async function runSkill( usage: totalUsage, durationMs: Date.now() - startTime, model: options.model, - files: fileResults.map((fr) => ({ - filename: fr.filename, - findings: fr.result.findings.length, - durationMs: fr.durationMs, - usage: fr.result.usage, - })), + files: buildFileReports( + fileResults.map((fr) => ({ + filename: fr.filename, + durationMs: fr.durationMs, + usage: fr.result.usage, + })), + finalFindings, + ), }; if (skippedFiles.length > 0) { report.skippedFiles = skippedFiles; diff --git a/src/sdk/report-files.ts b/src/sdk/report-files.ts new file mode 100644 index 00000000..01002cfc --- /dev/null +++ b/src/sdk/report-files.ts @@ -0,0 +1,27 @@ +import type { FileReport, Finding, UsageStats } from '../types/index.js'; + +export interface FileReportInput { + filename: string; + durationMs?: number; + usage?: UsageStats; +} + +/** + * Return whether a final finding should be counted against a file. + */ +export function findingAppliesToFile(finding: Finding, filename: string): boolean { + if (finding.location?.path === filename) return true; + return finding.additionalLocations?.some((location) => location.path === filename) ?? false; +} + +/** + * Count final findings per file while preserving timing and usage metadata. + */ +export function buildFileReports(files: FileReportInput[], findings: Finding[]): FileReport[] { + return files.map((file) => ({ + filename: file.filename, + findings: findings.filter((finding) => findingAppliesToFile(finding, file.filename)).length, + durationMs: file.durationMs, + usage: file.usage, + })); +}