mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-20 22:14:36 +00:00
An SDK session's opening prompt and a subagent's task prompt are written by a program: they repeat by design and have no home in CLAUDE.md. Both are flagged on the entry, but a user entry over the parser's large-line threshold comes back without its root flags - routine for generated prompts, which are exactly the long ones - so the markers are read off the ends of the raw line, where the fields sit either side of the oversized message.
649 lines
26 KiB
TypeScript
649 lines
26 KiB
TypeScript
import { describe, it, expect, afterAll, afterEach, beforeEach, vi } from 'vitest'
|
|
import { Writable } from 'node:stream'
|
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs'
|
|
import { tmpdir } from 'os'
|
|
import { join } from 'path'
|
|
import * as fsUtils from '../src/fs-utils.js'
|
|
|
|
vi.mock('os', async () => {
|
|
const actual = await vi.importActual<typeof import('os')>('os')
|
|
const fs = await vi.importActual<typeof import('fs')>('fs')
|
|
const fakeHome = fs.mkdtempSync(actual.tmpdir() + '/codeburn-home-')
|
|
fs.mkdirSync(fakeHome + '/.claude', { recursive: true })
|
|
process.env['CODEBURN_TEST_FAKE_HOME'] = fakeHome
|
|
return { ...actual, homedir: () => fakeHome }
|
|
})
|
|
|
|
const FAKE_HOME_FOR_MOCK = process.env['CODEBURN_TEST_FAKE_HOME']!
|
|
|
|
import {
|
|
detectBloatedClaudeMd,
|
|
detectUnusedMcp,
|
|
detectBashBloat,
|
|
detectGhostCommands,
|
|
loadMcpConfigs,
|
|
scanJsonlFile,
|
|
scanAndDetect,
|
|
detectRecurringContext,
|
|
renderOptimize,
|
|
type SessionOpener,
|
|
type ToolCall,
|
|
} from '../src/optimize.js'
|
|
import {
|
|
estimateContextBudget,
|
|
discoverProjectCwd,
|
|
} from '../src/context-budget.js'
|
|
import type { ProjectSummary } from '../src/types.js'
|
|
import { runOptimizeApply } from '../src/act/optimize-apply.js'
|
|
|
|
// ============================================================================
|
|
// Helpers for filesystem fixtures
|
|
// ============================================================================
|
|
|
|
const FIXTURE_ROOTS: string[] = [FAKE_HOME_FOR_MOCK]
|
|
|
|
function makeFixtureRoot(): string {
|
|
const dir = mkdtempSync(join(tmpdir(), 'codeburn-test-'))
|
|
FIXTURE_ROOTS.push(dir)
|
|
return dir
|
|
}
|
|
|
|
function writeFile(path: string, content: string): void {
|
|
mkdirSync(join(path, '..'), { recursive: true })
|
|
writeFileSync(path, content)
|
|
}
|
|
|
|
function touchOld(path: string, daysAgo: number): void {
|
|
const past = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000)
|
|
utimesSync(path, past, past)
|
|
}
|
|
|
|
afterAll(() => {
|
|
for (const dir of FIXTURE_ROOTS) {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
// ============================================================================
|
|
// detectBloatedClaudeMd (including @-import expansion)
|
|
// ============================================================================
|
|
|
|
describe('detectBloatedClaudeMd', () => {
|
|
it('flags a CLAUDE.md with more than 200 lines', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
const content = Array.from({ length: 300 }, (_, i) => `line ${i}`).join('\n')
|
|
writeFile(join(projectDir, 'CLAUDE.md'), content)
|
|
const finding = detectBloatedClaudeMd(new Set([projectDir]))
|
|
expect(finding).not.toBeNull()
|
|
})
|
|
|
|
it('expands @-imports and counts transitive load', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(
|
|
join(projectDir, 'CLAUDE.md'),
|
|
'line 1\nline 2\n@./rules.md\n@./conventions.md\n',
|
|
)
|
|
writeFile(join(projectDir, 'rules.md'), Array.from({ length: 120 }, (_, i) => `rule ${i}`).join('\n'))
|
|
writeFile(join(projectDir, 'conventions.md'), Array.from({ length: 120 }, (_, i) => `conv ${i}`).join('\n'))
|
|
const finding = detectBloatedClaudeMd(new Set([projectDir]))
|
|
expect(finding).not.toBeNull()
|
|
expect(finding!.explanation).toContain('2 @-imports')
|
|
})
|
|
|
|
it('does not flag a lean CLAUDE.md under 200 lines with no imports', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(join(projectDir, 'CLAUDE.md'), 'just a few\nlines\nhere\n')
|
|
expect(detectBloatedClaudeMd(new Set([projectDir]))).toBeNull()
|
|
})
|
|
|
|
it('does not recurse infinitely on circular @-imports', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(join(projectDir, 'CLAUDE.md'), '@./a.md\n')
|
|
writeFile(join(projectDir, 'a.md'), '@./b.md\n')
|
|
writeFile(join(projectDir, 'b.md'), '@./a.md\n')
|
|
expect(() => detectBloatedClaudeMd(new Set([projectDir]))).not.toThrow()
|
|
})
|
|
|
|
it('ignores @ tokens that are not paths (emails, npm scopes)', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(
|
|
join(projectDir, 'CLAUDE.md'),
|
|
Array.from({ length: 250 }, (_, i) =>
|
|
i === 10 ? '@user@example.com' :
|
|
i === 20 ? '@org/package' :
|
|
`line ${i}`
|
|
).join('\n'),
|
|
)
|
|
const finding = detectBloatedClaudeMd(new Set([projectDir]))
|
|
expect(finding).not.toBeNull()
|
|
// "with N @-imports" suffix appears only when non-zero imports were resolved
|
|
expect(finding!.explanation).not.toMatch(/with \d+ @-import/)
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// loadMcpConfigs + detectUnusedMcp
|
|
// ============================================================================
|
|
|
|
describe('loadMcpConfigs', () => {
|
|
it('returns empty map when no configs exist', () => {
|
|
const root = makeFixtureRoot()
|
|
const servers = loadMcpConfigs([root])
|
|
expect(servers.size).toBe(0)
|
|
})
|
|
|
|
it('reads servers from project .mcp.json', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
|
mcpServers: { foo: { command: 'foo' }, bar: { command: 'bar' } },
|
|
}))
|
|
const servers = loadMcpConfigs([projectDir])
|
|
expect(servers.has('foo')).toBe(true)
|
|
expect(servers.has('bar')).toBe(true)
|
|
})
|
|
|
|
it('normalizes server names by replacing colons with underscores', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
|
mcpServers: { 'plugin:context7:context7': { command: 'ctx' } },
|
|
}))
|
|
const servers = loadMcpConfigs([projectDir])
|
|
expect(servers.has('plugin_context7_context7')).toBe(true)
|
|
expect(servers.get('plugin_context7_context7')!.original).toBe('plugin:context7:context7')
|
|
})
|
|
|
|
it('handles malformed JSON without crashing', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(join(projectDir, '.mcp.json'), '{ not valid json')
|
|
expect(() => loadMcpConfigs([projectDir])).not.toThrow()
|
|
expect(loadMcpConfigs([projectDir]).size).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('detectUnusedMcp', () => {
|
|
it('flags servers configured but never called', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
|
mcpServers: { ghost: { command: 'x' } },
|
|
}))
|
|
const configFile = join(projectDir, '.mcp.json')
|
|
touchOld(configFile, 30)
|
|
const finding = detectUnusedMcp([], [], new Set([projectDir]))
|
|
expect(finding).not.toBeNull()
|
|
expect(finding!.explanation).toContain('ghost')
|
|
})
|
|
|
|
it('does not flag servers configured within 24 hours', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
|
mcpServers: { freshly_added: { command: 'x' } },
|
|
}))
|
|
expect(detectUnusedMcp([], [], new Set([projectDir]))).toBeNull()
|
|
})
|
|
|
|
it('does not flag servers that were called', () => {
|
|
const root = makeFixtureRoot()
|
|
const projectDir = join(root, 'myapp')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({
|
|
mcpServers: { used: { command: 'x' } },
|
|
}))
|
|
touchOld(join(projectDir, '.mcp.json'), 30)
|
|
const calls: ToolCall[] = [
|
|
{ name: 'mcp__used__some_tool', input: {}, sessionId: 's1', project: 'p1' },
|
|
]
|
|
expect(detectUnusedMcp(calls, [], new Set([projectDir]))).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// detectBashBloat
|
|
// ============================================================================
|
|
|
|
describe('detectBashBloat', () => {
|
|
it('flags when env var is unset (uses default 30K)', () => {
|
|
const finding = detectBashBloat()
|
|
expect(finding).not.toBeNull()
|
|
expect(finding!.impact).toBe('medium')
|
|
})
|
|
|
|
it('does not flag when env var is at recommended 15K', () => {
|
|
process.env['BASH_MAX_OUTPUT_LENGTH'] = '15000'
|
|
expect(detectBashBloat()).toBeNull()
|
|
})
|
|
|
|
it('does not flag when env var is below recommended', () => {
|
|
process.env['BASH_MAX_OUTPUT_LENGTH'] = '10000'
|
|
expect(detectBashBloat()).toBeNull()
|
|
})
|
|
|
|
it('flags when env var is above 15K', () => {
|
|
process.env['BASH_MAX_OUTPUT_LENGTH'] = '50000'
|
|
const finding = detectBashBloat()
|
|
expect(finding).not.toBeNull()
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// detectGhostCommands (the pure-function ghost detector)
|
|
// ============================================================================
|
|
|
|
describe('detectGhostCommands', () => {
|
|
it('returns null when no commands are defined', async () => {
|
|
expect(await detectGhostCommands([])).toBeNull()
|
|
})
|
|
|
|
it('does not match /tmp or /usr or other path prefixes as command usage', async () => {
|
|
const messages = [
|
|
'check /tmp/debug.log',
|
|
'look at /usr/local/bin',
|
|
'rm -rf /var/cache',
|
|
]
|
|
expect(await detectGhostCommands(messages)).toBeNull()
|
|
})
|
|
|
|
it('matches <command-name> tags in user messages', async () => {
|
|
const messages = ['<command-name>review</command-name>']
|
|
expect(await detectGhostCommands(messages)).toBeNull()
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// scanJsonlFile
|
|
// ============================================================================
|
|
|
|
describe('scanJsonlFile', () => {
|
|
it('returns empty result for nonexistent file', async () => {
|
|
const result = await scanJsonlFile('/nonexistent/path.jsonl', 'p1', undefined)
|
|
expect(result.calls).toEqual([])
|
|
expect(result.cwds).toEqual([])
|
|
expect(result.apiCalls).toEqual([])
|
|
expect(result.userMessages).toEqual([])
|
|
})
|
|
|
|
it('parses tool_use blocks from assistant entries', async () => {
|
|
const root = makeFixtureRoot()
|
|
const filePath = join(root, 'session.jsonl')
|
|
const now = new Date().toISOString()
|
|
const lines = [
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
timestamp: now,
|
|
message: {
|
|
content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/x/foo.ts' } }],
|
|
},
|
|
}),
|
|
]
|
|
writeFile(filePath, lines.join('\n'))
|
|
const result = await scanJsonlFile(filePath, 'p1', undefined)
|
|
expect(result.calls).toHaveLength(1)
|
|
expect(result.calls[0].name).toBe('Read')
|
|
})
|
|
|
|
it('skips malformed JSONL lines without crashing', async () => {
|
|
const root = makeFixtureRoot()
|
|
const filePath = join(root, 'session.jsonl')
|
|
writeFile(filePath, 'this is not json\n{broken\n{"type":"assistant","message":{"content":[]}}\n')
|
|
const result = await scanJsonlFile(filePath, 'p1', undefined)
|
|
expect(result.calls).toEqual([])
|
|
})
|
|
|
|
it('uses readSessionLines (streaming) rather than readSessionFile (full-string load)', async () => {
|
|
const readSessionLinesSpy = vi.spyOn(fsUtils, 'readSessionLines')
|
|
const readSessionFileSpy = vi.spyOn(fsUtils, 'readSessionFile')
|
|
const root = makeFixtureRoot()
|
|
const filePath = join(root, 'session.jsonl')
|
|
const now = new Date().toISOString()
|
|
writeFile(filePath, JSON.stringify({
|
|
type: 'assistant', timestamp: now,
|
|
message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] },
|
|
}))
|
|
await scanJsonlFile(filePath, 'p1', undefined)
|
|
expect(readSessionLinesSpy).toHaveBeenCalledWith(filePath, undefined, { largeLineAsBuffer: true })
|
|
expect(readSessionFileSpy).not.toHaveBeenCalled()
|
|
readSessionLinesSpy.mockRestore()
|
|
readSessionFileSpy.mockRestore()
|
|
})
|
|
|
|
it('processes all entries in a large multi-line file without truncation', async () => {
|
|
const root = makeFixtureRoot()
|
|
const filePath = join(root, 'session.jsonl')
|
|
const now = new Date().toISOString()
|
|
const ENTRY_COUNT = 500
|
|
const lines = Array.from({ length: ENTRY_COUNT }, (_, i) =>
|
|
JSON.stringify({
|
|
type: 'assistant',
|
|
timestamp: now,
|
|
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: `/file-${i}.ts` } }] },
|
|
}),
|
|
)
|
|
writeFile(filePath, lines.join('\n'))
|
|
const result = await scanJsonlFile(filePath, 'p1', undefined)
|
|
expect(result.calls).toHaveLength(ENTRY_COUNT)
|
|
})
|
|
|
|
it('respects date-range filter for assistant entries', async () => {
|
|
const root = makeFixtureRoot()
|
|
const filePath = join(root, 'session.jsonl')
|
|
const old = '2020-01-01T00:00:00Z'
|
|
const now = new Date().toISOString()
|
|
writeFile(filePath, [
|
|
JSON.stringify({
|
|
type: 'assistant', timestamp: old,
|
|
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/old' } }] },
|
|
}),
|
|
JSON.stringify({
|
|
type: 'assistant', timestamp: now,
|
|
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/new' } }] },
|
|
}),
|
|
].join('\n'))
|
|
const today = new Date()
|
|
const start = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
|
const result = await scanJsonlFile(filePath, 'p1', { start, end: today })
|
|
expect(result.calls).toHaveLength(1)
|
|
expect((result.calls[0].input as Record<string, unknown>).file_path).toBe('/new')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// detectRecurringContext
|
|
// ============================================================================
|
|
|
|
describe('detectRecurringContext', () => {
|
|
// ~2.2 KB, comfortably over the 1.5 KB floor.
|
|
const BRIEF = 'PROJECT BRIEF\n' + 'Ship the invoice importer behind a flag. '.repeat(53)
|
|
const TOKENS_PER_CHAR = 0.25
|
|
|
|
type Opener = { text: string; project?: string }
|
|
|
|
async function openersFor(sessions: Opener[]): Promise<SessionOpener[]> {
|
|
const root = makeFixtureRoot()
|
|
const now = new Date().toISOString()
|
|
const openers: SessionOpener[] = []
|
|
for (const [i, { text, project }] of sessions.entries()) {
|
|
const filePath = join(root, `s${i}.jsonl`)
|
|
writeFile(filePath, JSON.stringify({ type: 'user', timestamp: now, message: { content: text } }))
|
|
const result = await scanJsonlFile(filePath, project ?? 'my-app', undefined)
|
|
openers.push(...result.openers)
|
|
}
|
|
return openers
|
|
}
|
|
|
|
const repeat = (n: number, text = BRIEF, project?: string): Opener[] =>
|
|
Array.from({ length: n }, () => ({ text, project }))
|
|
|
|
it('flags a block that opens the session threshold worth of sessions', async () => {
|
|
const finding = detectRecurringContext(await openersFor(repeat(5)))
|
|
expect(finding).not.toBeNull()
|
|
expect(finding!.id).toBe('recurring-context')
|
|
expect(finding!.title).toBe(`Same ${(BRIEF.length / 1024).toFixed(1)} KB block pasted at the start of 5 sessions`)
|
|
// Only the four repeats are recoverable; the first paste is the honest cost.
|
|
expect(finding!.tokensSaved).toBe(Math.round(4 * BRIEF.length * TOKENS_PER_CHAR))
|
|
expect(finding!.fix.type).toBe('paste')
|
|
expect(finding!.fix.type === 'paste' && finding!.fix.destination).toBe('prompt')
|
|
})
|
|
|
|
it('renders the finding with its preview and destination header', async () => {
|
|
const finding = detectRecurringContext(await openersFor(repeat(5)))!
|
|
const out = renderOptimize([finding], 0.00001, '30 Days', 10, 5, 100, 80, 'B', [], [])
|
|
.replace(/\u001b\[[0-9;]*m/g, '')
|
|
expect(out).toContain(finding.title)
|
|
expect(out).toContain('PROJECT BRIEF Ship the invoice importer')
|
|
expect(out).toContain('Ask Claude in the current session')
|
|
expect(out).toContain('Move it into CLAUDE.md if it is a standing rule')
|
|
})
|
|
|
|
it('stays quiet below the session threshold', async () => {
|
|
expect(detectRecurringContext(await openersFor(repeat(4)))).toBeNull()
|
|
})
|
|
|
|
it('ignores short openers however often they repeat', async () => {
|
|
expect(detectRecurringContext(await openersFor(repeat(20, 'continue')))).toBeNull()
|
|
expect(detectRecurringContext(await openersFor(repeat(20, 'yes')))).toBeNull()
|
|
})
|
|
|
|
it('groups sessions whose block differs only in whitespace', async () => {
|
|
const reflowed = BRIEF.replace(/ /g, ' ').replace(/\n/g, '\n\n')
|
|
const finding = detectRecurringContext(await openersFor([...repeat(3), ...repeat(2, reflowed)]))
|
|
expect(finding).not.toBeNull()
|
|
expect(finding!.title).toContain('start of 5 sessions')
|
|
})
|
|
|
|
it('ignores injected system reminders and slash-command wrappers', async () => {
|
|
expect(detectRecurringContext(await openersFor(repeat(6, `<system-reminder>${BRIEF}</system-reminder>`)))).toBeNull()
|
|
expect(detectRecurringContext(await openersFor(repeat(6, `<command-name>/brief</command-name>${BRIEF}`)))).toBeNull()
|
|
})
|
|
|
|
it('skips prompts a program wrote: SDK sessions and subagent transcripts', async () => {
|
|
const root = makeFixtureRoot()
|
|
const now = new Date().toISOString()
|
|
const openers: SessionOpener[] = []
|
|
for (const [i, entry] of [{ promptSource: 'sdk' }, { isSidechain: true }].entries()) {
|
|
for (let j = 0; j < 6; j++) {
|
|
const filePath = join(root, `machine-${i}-${j}.jsonl`)
|
|
writeFile(filePath, JSON.stringify({ type: 'user', timestamp: now, ...entry, message: { content: BRIEF } }))
|
|
openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers)
|
|
}
|
|
}
|
|
expect(openers).toEqual([])
|
|
expect(detectRecurringContext(openers)).toBeNull()
|
|
})
|
|
|
|
// Over 32 KB the JSONL parser returns a reduced entry without the root
|
|
// flags, so the markers have to be read off the raw line.
|
|
it('skips machine-written prompts too large for the parser to keep flags on', async () => {
|
|
const root = makeFixtureRoot()
|
|
const now = new Date().toISOString()
|
|
const huge = BRIEF + 'x'.repeat(40_000)
|
|
const openers: SessionOpener[] = []
|
|
for (let i = 0; i < 6; i++) {
|
|
const filePath = join(root, `huge-${i}.jsonl`)
|
|
// Field order matters: the flags land past the head, behind the very
|
|
// message that made the line large.
|
|
writeFile(filePath, JSON.stringify({
|
|
isSidechain: false, type: 'user', message: { content: huge }, timestamp: now, promptSource: 'sdk',
|
|
}))
|
|
openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers)
|
|
}
|
|
expect(openers).toEqual([])
|
|
})
|
|
|
|
it('only counts the first message of a session as its opener', async () => {
|
|
const root = makeFixtureRoot()
|
|
const now = new Date().toISOString()
|
|
const openers: SessionOpener[] = []
|
|
for (let i = 0; i < 6; i++) {
|
|
const filePath = join(root, `late-${i}.jsonl`)
|
|
writeFile(filePath, [
|
|
JSON.stringify({ type: 'user', timestamp: now, message: { content: 'go on' } }),
|
|
JSON.stringify({ type: 'user', timestamp: now, message: { content: BRIEF } }),
|
|
].join('\n'))
|
|
openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers)
|
|
}
|
|
expect(detectRecurringContext(openers)).toBeNull()
|
|
})
|
|
|
|
it('names the project when the block is confined to one, and counts them otherwise', async () => {
|
|
const single = detectRecurringContext(await openersFor(repeat(5, BRIEF, '-Users-me-Projects-codeburn')))
|
|
expect(single!.explanation).toContain('5 sessions in codeburn')
|
|
|
|
const spread = detectRecurringContext(await openersFor([
|
|
...repeat(3, BRIEF, '-Users-me-Projects-codeburn'),
|
|
...repeat(2, BRIEF, '-Users-me-Projects-dash'),
|
|
]))
|
|
expect(spread!.explanation).toContain('5 sessions in 2 projects')
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// scanAndDetect (top-level integration)
|
|
// ============================================================================
|
|
|
|
describe('scanAndDetect', () => {
|
|
it('returns healthy result for empty projects', async () => {
|
|
const result = await scanAndDetect([])
|
|
expect(result.findings).toEqual([])
|
|
expect(result.healthScore).toBe(100)
|
|
expect(result.healthGrade).toBe('A')
|
|
expect(result.costRate).toBe(0)
|
|
})
|
|
|
|
// The session scan only ever reads Claude Code transcripts, so under a
|
|
// non-Claude --provider it used to report Claude-derived findings beside a
|
|
// header scoped to the other provider - e.g. `optimize --provider codex`
|
|
// printing a read/edit ratio counted from Claude sessions.
|
|
describe('provider scoping', () => {
|
|
// These fixtures live in the shared fake home, so they have to come back
|
|
// out: later suites in this file assert on an otherwise empty ~/.claude.
|
|
const CLAUDE_DIR = join(FAKE_HOME_FOR_MOCK, '.claude')
|
|
afterEach(() => {
|
|
for (const sub of ['projects', 'skills']) {
|
|
rmSync(join(CLAUDE_DIR, sub), { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
function claudeSessionWithEditHeavyTurns(): void {
|
|
const projectDir = join(CLAUDE_DIR, 'projects', 'provider-scope')
|
|
mkdirSync(projectDir, { recursive: true })
|
|
const now = new Date().toISOString()
|
|
const entry = (name: string, file: string) => JSON.stringify({
|
|
type: 'assistant', timestamp: now,
|
|
message: { content: [{ type: 'tool_use', name, input: { file_path: file } }] },
|
|
})
|
|
const lines = [entry('Read', '/src/a.ts')]
|
|
for (let i = 0; i < 12; i++) lines.push(entry('Edit', `/src/f${i}.ts`))
|
|
writeFileSync(join(projectDir, 'session.jsonl'), lines.join('\n'))
|
|
}
|
|
|
|
// scanAndDetect memoises on (provider, range, project fingerprint) for 60s,
|
|
// and the cache is module-level, so tests that differ only in what is on
|
|
// disk would serve each other's results. `seed` moves the fingerprint so
|
|
// each case scans for real.
|
|
function projectFixture(seed: number): ProjectSummary {
|
|
return {
|
|
project: 'provider-scope',
|
|
projectPath: '/tmp/provider-scope',
|
|
sessions: [],
|
|
totalCostUSD: 1,
|
|
totalApiCalls: 13 + seed,
|
|
} as unknown as ProjectSummary
|
|
}
|
|
|
|
it('reports transcript-derived findings when scoped to claude', async () => {
|
|
claudeSessionWithEditHeavyTurns()
|
|
const result = await scanAndDetect([projectFixture(1)], undefined, 'claude')
|
|
expect(result.findings.map(f => f.id)).toContain('read-edit-ratio')
|
|
})
|
|
|
|
it('omits transcript-derived findings when scoped to another provider', async () => {
|
|
claudeSessionWithEditHeavyTurns()
|
|
mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true })
|
|
writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n')
|
|
|
|
const result = await scanAndDetect([projectFixture(2)], undefined, 'codex')
|
|
const ids = result.findings.map(f => f.id)
|
|
|
|
expect(ids).not.toContain('read-edit-ratio')
|
|
// An unmeasured skill must not be reported as an unused one: the scan
|
|
// returns nothing under this filter, which is not evidence of disuse.
|
|
expect(ids).not.toContain('unused-skills')
|
|
})
|
|
|
|
// The apply path reaches scanAndDetect through its own entry point, so it
|
|
// needs its own guard: `unused-skills` is appliable, and its plan moves
|
|
// directories out of ~/.claude/skills. Reporting a Codex-labelled finding
|
|
// is a wrong number; offering to archive every skill off one is a wrong
|
|
// number with side effects.
|
|
async function applyDryRun(provider: string): Promise<string> {
|
|
const chunks: string[] = []
|
|
const output = new Writable({ write(c, _e, cb) { chunks.push(String(c)); cb() } })
|
|
const errorOutput = new Writable({ write(_c, _e, cb) { cb() } })
|
|
await runOptimizeApply([projectFixture(3)], undefined, { provider, dryRun: true, output, errorOutput })
|
|
return chunks.join('')
|
|
}
|
|
|
|
it('plans no applies from Claude findings when scoped to another provider', async () => {
|
|
claudeSessionWithEditHeavyTurns()
|
|
mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true })
|
|
writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n')
|
|
|
|
const codex = await applyDryRun('codex')
|
|
expect(codex).toContain('No appliable config-class fixes')
|
|
expect(codex).not.toContain('never-invoked')
|
|
|
|
const claude = await applyDryRun('claude')
|
|
expect(claude).toContain('never-invoked')
|
|
})
|
|
})
|
|
})
|
|
|
|
// ============================================================================
|
|
// context-budget
|
|
// ============================================================================
|
|
|
|
describe('estimateContextBudget', () => {
|
|
it('returns only system base when project has no config', async () => {
|
|
const root = makeFixtureRoot()
|
|
const budget = await estimateContextBudget(root)
|
|
expect(budget.total).toBeGreaterThan(0)
|
|
expect(budget.mcpTools.count).toBe(0)
|
|
expect(budget.skills.count).toBe(0)
|
|
})
|
|
|
|
it('includes MCP tools from project .mcp.json', async () => {
|
|
const root = makeFixtureRoot()
|
|
writeFile(join(root, '.mcp.json'), JSON.stringify({
|
|
mcpServers: { a: { command: 'x' }, b: { command: 'x' } },
|
|
}))
|
|
const budget = await estimateContextBudget(root)
|
|
expect(budget.mcpTools.count).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('includes memory file tokens from CLAUDE.md', async () => {
|
|
const root = makeFixtureRoot()
|
|
writeFile(join(root, 'CLAUDE.md'), 'Project context for Claude.\n')
|
|
const budget = await estimateContextBudget(root)
|
|
expect(budget.memory.count).toBeGreaterThan(0)
|
|
expect(budget.memory.tokens).toBeGreaterThan(0)
|
|
})
|
|
})
|
|
|
|
describe('discoverProjectCwd', () => {
|
|
it('returns null for empty directory', async () => {
|
|
const root = makeFixtureRoot()
|
|
expect(await discoverProjectCwd(root)).toBeNull()
|
|
})
|
|
|
|
it('returns null for directory with no jsonl files', async () => {
|
|
const root = makeFixtureRoot()
|
|
writeFile(join(root, 'readme.txt'), 'hi')
|
|
expect(await discoverProjectCwd(root)).toBeNull()
|
|
})
|
|
|
|
it('extracts cwd from the first jsonl entry', async () => {
|
|
const root = makeFixtureRoot()
|
|
const entry = JSON.stringify({ type: 'assistant', cwd: '/Users/test/project', timestamp: new Date().toISOString() })
|
|
writeFile(join(root, 'session.jsonl'), entry + '\n')
|
|
expect(await discoverProjectCwd(root)).toBe('/Users/test/project')
|
|
})
|
|
})
|