From 8e7cf4dde62f5cbd7111e1b1f2a4c32e0ea5146a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 7 May 2026 10:44:01 -0700 Subject: [PATCH 1/2] fix(sdk): Preserve findings when verification aborts Treat interrupted verification as inconclusive instead of rejecting candidates. This keeps partial run output aligned with findings already shown by the live UI. Co-Authored-By: GPT-5 Codex --- src/sdk/analyze.test.ts | 75 +++++++++++++++++++++++++++++++++++++++++ src/sdk/verify.test.ts | 22 ++++-------- src/sdk/verify.ts | 20 ++++------- 3 files changed, 87 insertions(+), 30 deletions(-) diff --git a/src/sdk/analyze.test.ts b/src/sdk/analyze.test.ts index 421f48eb..e44583b7 100644 --- a/src/sdk/analyze.test.ts +++ b/src/sdk/analyze.test.ts @@ -139,6 +139,37 @@ function makeContextWithTwoHunks(): EventContext { }; } +function makeContextWithOneHunk(): EventContext { + return { + eventType: 'pull_request', + action: 'opened', + repository: { owner: 'o', name: 'r', fullName: 'o/r', defaultBranch: 'main' }, + repoPath: '/tmp/repo', + pullRequest: { + number: 1, + title: 'Test PR', + body: '', + author: 'test', + baseBranch: 'main', + headBranch: 'feature', + headSha: 'head', + baseSha: 'base', + files: [{ + filename: 'src/example.ts', + status: 'modified', + additions: 1, + deletions: 1, + patch: [ + '@@ -10,1 +10,1 @@', + '-old10', + '+new10', + ].join('\n'), + chunks: 1, + }], + }, + }; +} + describe('filterOutOfRangeFindings', () => { const hunkRange = { start: 10, end: 20 }; @@ -420,6 +451,50 @@ describe('runSkill', () => { vi.restoreAllMocks(); }); + it('preserves candidate findings when verification is interrupted', async () => { + const controller = new AbortController(); + const runSkillMock = vi.fn() + .mockResolvedValueOnce({ + result: { + status: 'success', + text: JSON.stringify({ + findings: [makeFinding(10, 'candidate-finding')], + }), + errors: [], + usage: makeUsage(), + }, + }) + .mockImplementationOnce(async () => { + controller.abort(); + throw makeAbortError(); + }); + vi.mocked(getRuntime).mockReturnValue({ + name: 'claude', + runSkill: runSkillMock, + runAuxiliary: vi.fn(), + runSynthesis: vi.fn(), + } as unknown as Runtime); + + const report = await runSkill( + { + name: 'security-review', + description: 'Security review.', + prompt: 'Return findings as JSON.', + }, + makeContextWithOneHunk(), + { abortController: controller }, + ); + + expect(runSkillMock).toHaveBeenCalledTimes(2); + expect(report.findings).toEqual([ + expect.objectContaining({ + title: 'Finding at line 10', + location: { path: 'src/example.ts', startLine: 10 }, + }), + ]); + expect(report.files?.[0]?.findings).toBe(1); + }); + it('preserves partial findings when the shared circuit opens mid-run', async () => { const controller = new AbortController(); const circuitBreaker = new ProviderFailureCircuitBreaker({ diff --git a/src/sdk/verify.test.ts b/src/sdk/verify.test.ts index 6d503d3b..ba815268 100644 --- a/src/sdk/verify.test.ts +++ b/src/sdk/verify.test.ts @@ -242,7 +242,7 @@ describe('verifyFindings', () => { expect(result.findings).toEqual([finding]); }); - it('drops unverified findings when verification is already aborted', async () => { + it('keeps candidate findings when verification is already aborted', async () => { const runtime = mockRuntime('{"verdict":"keep"}'); vi.mocked(getRuntime).mockReturnValue(runtime); const abortController = new AbortController(); @@ -257,17 +257,12 @@ describe('verifyFindings', () => { onFindingProcessing, }); - expect(result.findings).toEqual([]); + expect(result.findings).toEqual([finding]); expect(runtime.runSkill).not.toHaveBeenCalled(); - expect(onFindingProcessing).toHaveBeenCalledWith({ - stage: 'verification', - action: 'rejected', - finding, - reason: 'verification aborted before start', - }); + expect(onFindingProcessing).not.toHaveBeenCalled(); }); - it('drops unverified findings when verification aborts before verdict', async () => { + it('keeps candidate findings when verification aborts before verdict', async () => { const abortController = new AbortController(); const runtime: Runtime = { name: 'claude', @@ -289,13 +284,8 @@ describe('verifyFindings', () => { onFindingProcessing, }); - expect(result.findings).toEqual([]); - expect(onFindingProcessing).toHaveBeenCalledWith({ - stage: 'verification', - action: 'rejected', - finding, - reason: 'verification aborted before verdict', - }); + expect(result.findings).toEqual([finding]); + expect(onFindingProcessing).not.toHaveBeenCalled(); }); it('propagates authentication errors reported by the verifier runtime', async () => { diff --git a/src/sdk/verify.ts b/src/sdk/verify.ts index aab08933..5f9e2c80 100644 --- a/src/sdk/verify.ts +++ b/src/sdk/verify.ts @@ -220,18 +220,10 @@ function notifyVerdict( } } -function rejectUnverifiedFinding( - options: VerifyFindingsOptions, - finding: Finding, - reason: string -): VerificationTaskResult { - options.onFindingProcessing?.({ - stage: 'verification', - action: 'rejected', - finding, - reason, - }); - return { finding: undefined }; +function keepFindingAfterInterruptedVerification(finding: Finding): VerificationTaskResult { + // An abort is inconclusive, not a verifier rejection. Preserve candidates so + // interrupted runs report the partial findings already collected. + return { finding }; } /** @@ -254,7 +246,7 @@ export async function verifyFindings( VERIFICATION_CONCURRENCY, async (finding) => { if (options.abortController?.signal.aborted) { - return rejectUnverifiedFinding(options, finding, 'verification aborted before start'); + return keepFindingAfterInterruptedVerification(finding); } try { @@ -284,7 +276,7 @@ export async function verifyFindings( return { finding: next ?? undefined, usage: result?.usage }; } catch (error) { if (isAbortRequested(error, options.abortController)) { - return rejectUnverifiedFinding(options, finding, 'verification aborted before verdict'); + return keepFindingAfterInterruptedVerification(finding); } if (error instanceof WardenAuthenticationError) { From d156f99044d4951a4900cf5019de61f9c958a5ac Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 7 May 2026 10:53:44 -0700 Subject: [PATCH 2/2] fix(cli): Ignore script delimiter before run args Strip a leading script delimiter before parsing Warden arguments so pnpm script invocations do not turn it into a fake target. Also handle repeated shorthand verbosity like -vvv. Co-Authored-By: GPT-5 Codex --- src/cli/args.test.ts | 13 +++++++++++++ src/cli/args.ts | 12 +++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index be85fed7..e0559dc8 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -36,6 +36,14 @@ describe('parseCliArgs', () => { expect(result.options.skill).toBe('security-review'); }); + it('ignores a leading script delimiter before run arguments', () => { + const result = parseCliArgs(['--', '-vvv', 'src/', '--skill', 'code-review']); + expect(result.command).toBe('run'); + expect(result.options.targets).toEqual(['src/']); + expect(result.options.skill).toBe('code-review'); + expect(result.options.verbose).toBe(3); + }); + it('parses multiple file targets', () => { const result = parseCliArgs(['file1.ts', 'file2.ts', '--skill', 'security-review']); expect(result.options.targets).toEqual(['file1.ts', 'file2.ts']); @@ -176,6 +184,11 @@ describe('parseCliArgs', () => { expect(result.options.verbose).toBe(2); }); + it('parses repeated shorthand verbosity', () => { + const result = parseCliArgs(['-vvv']); + expect(result.options.verbose).toBe(3); + }); + it('parses --verbose flag', () => { const result = parseCliArgs(['--verbose']); expect(result.options.verbose).toBe(1); diff --git a/src/cli/args.ts b/src/cli/args.ts index 94637e4c..60379c69 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -249,15 +249,17 @@ function resolveColorOption(values: { color?: boolean; 'no-color'?: boolean }): } export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs { + const args = argv[0] === '--' ? argv.slice(1) : argv; + // Count -v flags before parsing (parseArgs doesn't handle multiple -v well) let verboseCount = 0; - const filteredArgv = argv.filter((arg) => { - if (arg === '-v' || arg === '--verbose') { - verboseCount++; + const filteredArgv = args.filter((arg) => { + if (/^-v+$/.test(arg)) { + verboseCount += arg.length - 1; return false; } - if (arg === '-vv') { - verboseCount += 2; + if (arg === '--verbose') { + verboseCount++; return false; } return true;