diff --git a/src/parser.ts b/src/parser.ts index f6a4656..4fd7e14 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3736,15 +3736,26 @@ async function runParse( ? { id: s.sourceId, label: s.sourceLabel, path: s.sourcePath, kind: s.sourceKind } : undefined, })) + // Claude is scanned through scanProjectDirs rather than parseProviderSources, so + // it needs the same provider-filter guard the durable-orphan loop below applies at + // its own level. Without it a --provider run still enters scanProjectDirs + // with an empty dirs list, and the orphan pass there (which reads the whole cached + // claude section) treats every cached file as "no longer discovered" and re-injects + // it into the result. Note this is deliberately NOT a `claudeDirs.length > 0` check: + // when claude IS in scope but every transcript has been pruned from disk, that + // orphan pass is exactly what keeps PR-attributed spend from vanishing. + const claudeInScope = !providerFilter || providerFilter === 'all' || providerFilter === 'claude' if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' }) let claudeProjects: ProjectSummary[] = [] - try { - claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly) - if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length }) - } catch (err) { - if (!isPermissionError(err)) throw err - process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`) - emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' }) + if (claudeInScope) { + try { + claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly) + if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length }) + } catch (err) { + if (!isPermissionError(err)) throw err + process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`) + emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' }) + } } const otherProjects: ProjectSummary[] = [] diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 5de2dad..211c41f 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -488,3 +488,114 @@ describe('(g) skill attribution is independent of turn category', () => { expect(session!.skillBreakdown['telemetry-review']?.turns).toBe(1) }) }) + +// ═══════════════════════════════════════════════════════════════════════════ +// (h) Provider filter isolates claude: a --provider run must not +// re-surface cached claude sessions through the orphan pass, while a run +// that DOES include claude still preserves PR-bearing orphans. +// ═══════════════════════════════════════════════════════════════════════════ +describe('(h) provider filter excludes claude from the orphan pass', () => { + const SYNTH_SOURCE = (path: string): SessionSource[] => + [{ path, project: 'synth-proj', provider: 'test-synthetic' }] + + // The provider lives on each parsed call, not on SessionSummary. + const providersOf = (projects: Awaited>): Set => + new Set(projects + .flatMap(p => p.sessions) + .flatMap(s => s.turns) + .flatMap(t => t.assistantCalls) + .map(c => c.provider)) + + const SYNTH_CALL: ParsedProviderCall = { + provider: 'test-synthetic', model: 'gpt-4o', + inputTokens: 10, outputTokens: 5, + cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0.25, tools: [], bashCommands: [], + skills: [], + timestamp: '2026-07-18T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'synth-isolation-call', + userMessage: '', sessionId: 'synth-isolation-session', + } + + // A claude transcript carrying a pr-link: `prLinks` is exactly what lets a + // cached entry survive the write-mode orphan gate, so it is the shape that + // leaks. Cost is deliberately far larger than the synthetic call's, so a leak + // is unmistakable rather than a rounding difference. + async function writeClaudeSessionWithPrLink(): Promise { + const projectDir = join(tmpHome, '.claude', 'projects', 'leaky-app') + await mkdir(projectDir, { recursive: true }) + const filePath = join(projectDir, 'session.jsonl') + await writeFile(filePath, [ + JSON.stringify({ + type: 'user', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:00.000Z', + message: { role: 'user', content: 'ship it' }, + }), + JSON.stringify({ + type: 'assistant', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:10.000Z', + message: { + id: 'msg-leak-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [{ type: 'text', text: 'done' }], + usage: { input_tokens: 900_000, output_tokens: 90_000 }, + }, + }), + JSON.stringify({ + type: 'pr-link', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:20.000Z', + prUrl: 'https://github.com/getagentseal/codeburn/pull/1', + }), + ].join('\n') + '\n') + return filePath + } + + it('does not surface cached claude sessions when filtering to another provider', async () => { + const synthFile = join(tmpHome, 'synth-isolation.txt') + await writeFile(synthFile, 'placeholder') + await writeClaudeSessionWithPrLink() + + _synthSources = SYNTH_SOURCE(synthFile) + _synthYields = [SYNTH_CALL] + + // Baseline: what the synthetic provider costs on its own, before anything + // claude-shaped has ever entered the session cache. Self-calibrating, since + // cost is re-derived from tokens by the pricing engine. + const baseline = await parseAllSessions(undefined, 'test-synthetic') + const synthOnlyCost = totalCost(baseline) + expect([...providersOf(baseline)]).toEqual(['test-synthetic']) + clearSessionCache() + + // Warm the session cache so the claude file is persisted WITH its prLinks. + const all = await parseAllSessions(undefined, 'all') + expect(providersOf(all)).toContain('claude') + expect(totalCost(all)).toBeGreaterThan(synthOnlyCost) + + clearSessionCache() + + // Filtering to the synthetic provider must yield ONLY its own spend. Before + // the fix, claudeDirs was empty yet scanProjectDirs still ran, so every + // cached PR-bearing claude file was treated as a pruned orphan and re-added. + const filtered = await parseAllSessions(undefined, 'test-synthetic') + + expect([...providersOf(filtered)]).toEqual(['test-synthetic']) + expect(totalCost(filtered)).toBeCloseTo(synthOnlyCost, 10) + }) + + it('still preserves a PR-bearing claude orphan when claude IS in scope', async () => { + const filePath = await writeClaudeSessionWithPrLink() + _synthSources = [] + _synthYields = [] + + const before = await parseAllSessions(undefined, 'all') + const costBefore = totalCost(before) + expect(costBefore).toBeGreaterThan(0) + + // Every claude transcript disappears from disk. Claude is still in scope, so + // the orphan pass must keep the PR-attributed spend alive — this is the case + // a naive `claudeDirs.length > 0` guard would silently break. + await unlink(filePath) + clearSessionCache() + + const after = await parseAllSessions(undefined, 'all') + expect(totalCost(after)).toBeCloseTo(costBefore, 10) + }) +})