mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 09:39:24 +00:00
Plans serialize the full post-edit file content at build time, so a target edited between the preview and the interactive confirm would be silently overwritten with stale content (recoverable via backup, but a silent violation of the dry-run-shows-exactly-what-changes principle). - PlannedChange edit/create variants gain optional expectedHash: sha256 of the raw on-disk bytes the plan was built from (hashed before the BOM strip), null when the plan expects the file to be absent, undefined to skip validation (framework back-compat). - plans.ts sets it everywhere a target is read: ConfigDocs hashes the raw buffer it parses, and the marker builders hash the file behind a shared markerChange helper. - runAction validates all expected hashes after snapshotting and before the first mutation; a mismatch throws "<path> changed since the plan was built; re-run codeburn optimize --apply", removes the backup dir, and journals nothing. No rollback is involved since nothing mutated. Tests: stale edit target rejected with no journal record and no backup dir left, expects-absent plan rejected when the file appeared, matching hash still applies, and a hash-less change still applies unchecked.
655 lines
29 KiB
TypeScript
655 lines
29 KiB
TypeScript
import { afterAll, describe, expect, it } from 'vitest'
|
||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||
import { existsSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import { createHash } from 'node:crypto'
|
||
import { PassThrough, Writable } from 'node:stream'
|
||
|
||
import { planFor, planFindings, type PlanContext } from '../src/act/plans.js'
|
||
import { renderApplyList, runOptimizeApply, type ApplyOptions } from '../src/act/optimize-apply.js'
|
||
import { runAction } from '../src/act/apply.js'
|
||
import { undoAction } from '../src/act/undo.js'
|
||
import { readRecords, shortId } from '../src/act/journal.js'
|
||
import {
|
||
detectBloatedClaudeMd,
|
||
detectDuplicateReads,
|
||
detectJunkReads,
|
||
detectLowReadEditRatio,
|
||
detectMcpToolCoverage,
|
||
} from '../src/optimize.js'
|
||
import type {
|
||
FindingApply,
|
||
FindingId,
|
||
McpServerCoverage,
|
||
ToolCall,
|
||
WasteAction,
|
||
WasteFinding,
|
||
} from '../src/optimize.js'
|
||
|
||
const roots: string[] = []
|
||
|
||
type Fixture = { root: string; home: string; project: string; actionsDir: string }
|
||
|
||
async function makeFixture(): Promise<Fixture> {
|
||
const root = await mkdtemp(join(tmpdir(), 'codeburn-optimize-apply-'))
|
||
roots.push(root)
|
||
const home = join(root, 'home')
|
||
const project = join(root, 'project')
|
||
await mkdir(home, { recursive: true })
|
||
await mkdir(project, { recursive: true })
|
||
return { root, home, project, actionsDir: join(root, 'actions') }
|
||
}
|
||
|
||
afterAll(async () => {
|
||
for (const root of roots) await rm(root, { recursive: true, force: true })
|
||
})
|
||
|
||
function makeFinding(id: FindingId, fix: WasteAction, apply?: FindingApply): WasteFinding {
|
||
return { id, title: id, explanation: '', impact: 'medium', tokensSaved: 1000, fix, ...(apply ? { apply } : {}) }
|
||
}
|
||
|
||
const CMD_FIX: WasteAction = { type: 'command', label: '', text: '' }
|
||
|
||
async function hashTree(dir: string): Promise<string> {
|
||
const h = createHash('sha256')
|
||
async function walk(d: string): Promise<void> {
|
||
const entries = (await readdir(d, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
|
||
for (const entry of entries) {
|
||
const full = join(d, entry.name)
|
||
if (entry.isDirectory()) {
|
||
h.update('D:' + full + '\n')
|
||
await walk(full)
|
||
} else {
|
||
h.update('F:' + full + '\n')
|
||
h.update(await readFile(full))
|
||
}
|
||
}
|
||
}
|
||
await walk(dir)
|
||
return h.digest('hex')
|
||
}
|
||
|
||
describe('mcp-remove plan', () => {
|
||
it('deletes exactly the named server, leaves other keys untouched, and undo restores byte-identical', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeJson = join(fx.home, '.claude.json')
|
||
const original = JSON.stringify({
|
||
mcpServers: { alpha: { command: 'a' }, beta: { command: 'b', args: ['x'] } },
|
||
numFoo: 3,
|
||
nested: { keep: true },
|
||
}, null, 2) + '\n'
|
||
await writeFile(claudeJson, original)
|
||
await writeFile(join(fx.project, '.mcp.json'), JSON.stringify({ mcpServers: { gamma: {} } }, null, 2) + '\n')
|
||
|
||
const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['beta'] })
|
||
const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project })
|
||
expect(plan).not.toBeNull()
|
||
expect(plan!.changes).toHaveLength(1)
|
||
expect(plan!.changes[0]!.path).toBe(claudeJson)
|
||
|
||
const rec = await runAction(plan!, fx.actionsDir)
|
||
|
||
const after = JSON.parse(await readFile(claudeJson, 'utf-8'))
|
||
expect(after.mcpServers).toEqual({ alpha: { command: 'a' } })
|
||
expect(after.numFoo).toBe(3)
|
||
expect(after.nested).toEqual({ keep: true })
|
||
// Untouched sibling config file.
|
||
expect(JSON.parse(await readFile(join(fx.project, '.mcp.json'), 'utf-8')).mcpServers).toEqual({ gamma: {} })
|
||
// 2-space indent + trailing newline contract.
|
||
expect(await readFile(claudeJson, 'utf-8')).toBe(JSON.stringify(after, null, 2) + '\n')
|
||
|
||
await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir })
|
||
expect(await readFile(claudeJson, 'utf-8')).toBe(original)
|
||
})
|
||
})
|
||
|
||
describe('mcp-project-scope plan', () => {
|
||
it('moves the entry from the global config into the keeper project .mcp.json, creating it when missing', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeJson = join(fx.home, '.claude.json')
|
||
const serverValue = { command: 'srv', args: ['--flag'], env: { A: '1' } }
|
||
const original = JSON.stringify({ mcpServers: { srv: serverValue }, other: 1 }, null, 2) + '\n'
|
||
await writeFile(claudeJson, original)
|
||
|
||
const keeper = join(fx.root, 'keeper')
|
||
await mkdir(keeper, { recursive: true })
|
||
const keeperMcp = join(keeper, '.mcp.json')
|
||
expect(existsSync(keeperMcp)).toBe(false)
|
||
|
||
const finding = makeFinding('mcp-project-scope', { type: 'paste', destination: 'prompt', label: '', text: '' }, {
|
||
kind: 'mcp-project-scope',
|
||
servers: [{ server: 'srv', keepProjects: [keeper], removeProjects: [] }],
|
||
})
|
||
const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project })
|
||
expect(plan).not.toBeNull()
|
||
|
||
const rec = await runAction(plan!, fx.actionsDir)
|
||
|
||
expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({})
|
||
expect(existsSync(keeperMcp)).toBe(true)
|
||
expect(JSON.parse(await readFile(keeperMcp, 'utf-8')).mcpServers).toEqual({ srv: serverValue })
|
||
|
||
await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir })
|
||
expect(await readFile(claudeJson, 'utf-8')).toBe(original)
|
||
expect(existsSync(keeperMcp)).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('unparseable config file', () => {
|
||
it('reports the parse error, skips that server, and still applies the servers it can read', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeJson = join(fx.home, '.claude.json')
|
||
await writeFile(claudeJson, JSON.stringify({ mcpServers: { good: { command: 'g' } } }, null, 2) + '\n')
|
||
const brokenMcp = join(fx.project, '.mcp.json')
|
||
await writeFile(brokenMcp, '{ this is not valid json,,, ')
|
||
|
||
const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['good', 'bad'] })
|
||
const { plan, notes } = planFindings([finding], { homeDir: fx.home, cwd: fx.project })[0]!
|
||
|
||
expect(notes.some(n => /could not parse/.test(n) && n.includes('.mcp.json'))).toBe(true)
|
||
expect(notes.some(n => n.includes('bad'))).toBe(true)
|
||
expect(plan).not.toBeNull()
|
||
expect(plan!.changes.map(c => c.path)).toEqual([claudeJson])
|
||
|
||
await runAction(plan!, fx.actionsDir)
|
||
expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({})
|
||
// The broken file is left exactly as-is.
|
||
expect(await readFile(brokenMcp, 'utf-8')).toBe('{ this is not valid json,,, ')
|
||
})
|
||
})
|
||
|
||
describe('archive plan', () => {
|
||
it('archives a skill dir and an agent file, round-trips undo, and suffixes a colliding name with -2', async () => {
|
||
const fx = await makeFixture()
|
||
const skillsDir = join(fx.home, '.claude', 'skills')
|
||
const agentsDir = join(fx.home, '.claude', 'agents')
|
||
await mkdir(join(skillsDir, 'foo'), { recursive: true })
|
||
await writeFile(join(skillsDir, 'foo', 'SKILL.md'), 'skill body')
|
||
// Pre-existing archive with the same name forces the -2 suffix.
|
||
await mkdir(join(skillsDir, '.archived', 'foo'), { recursive: true })
|
||
await writeFile(join(skillsDir, '.archived', 'foo', 'SKILL.md'), 'old archived')
|
||
await mkdir(agentsDir, { recursive: true })
|
||
await writeFile(join(agentsDir, 'bar.md'), 'agent body')
|
||
|
||
const skillFinding = makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] })
|
||
const skillPlan = planFor(skillFinding, { homeDir: fx.home, cwd: fx.project })
|
||
expect(skillPlan!.changes[0]).toMatchObject({
|
||
op: 'move',
|
||
path: join(skillsDir, 'foo'),
|
||
movedTo: join(skillsDir, '.archived', 'foo-2'),
|
||
})
|
||
const skillRec = await runAction(skillPlan!, fx.actionsDir)
|
||
expect(existsSync(join(skillsDir, 'foo'))).toBe(false)
|
||
expect(await readFile(join(skillsDir, '.archived', 'foo-2', 'SKILL.md'), 'utf-8')).toBe('skill body')
|
||
// The pre-existing archive is preserved.
|
||
expect(await readFile(join(skillsDir, '.archived', 'foo', 'SKILL.md'), 'utf-8')).toBe('old archived')
|
||
|
||
const agentFinding = makeFinding('unused-agents', CMD_FIX, { kind: 'archive', names: ['bar'] })
|
||
const agentPlan = planFor(agentFinding, { homeDir: fx.home, cwd: fx.project })
|
||
expect(agentPlan!.changes[0]).toMatchObject({
|
||
op: 'move',
|
||
path: join(agentsDir, 'bar.md'),
|
||
movedTo: join(agentsDir, '.archived', 'bar.md'),
|
||
})
|
||
const agentRec = await runAction(agentPlan!, fx.actionsDir)
|
||
expect(existsSync(join(agentsDir, 'bar.md'))).toBe(false)
|
||
|
||
await undoAction({ id: agentRec.id }, { actionsDir: fx.actionsDir })
|
||
await undoAction({ id: skillRec.id }, { actionsDir: fx.actionsDir })
|
||
expect(await readFile(join(agentsDir, 'bar.md'), 'utf-8')).toBe('agent body')
|
||
expect(await readFile(join(skillsDir, 'foo', 'SKILL.md'), 'utf-8')).toBe('skill body')
|
||
expect(existsSync(join(skillsDir, '.archived', 'foo-2'))).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('claude-md rule plan', () => {
|
||
it('appends a fresh marker block, replaces it in place on re-apply, and undo removes it', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeMd = join(fx.project, 'CLAUDE.md')
|
||
const original = '# Project\n\nExisting rules.\n'
|
||
await writeFile(claudeMd, original)
|
||
|
||
const first = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read before editing.' })
|
||
const firstPlan = planFor(first, { homeDir: fx.home, cwd: fx.project })
|
||
const firstRec = await runAction(firstPlan!, fx.actionsDir)
|
||
|
||
let body = await readFile(claudeMd, 'utf-8')
|
||
expect(body).toContain('# Project')
|
||
expect(body).toContain('<!-- codeburn:begin read-edit-ratio -->')
|
||
expect(body).toContain('Read before editing.')
|
||
expect(body).toContain('<!-- codeburn:end read-edit-ratio -->')
|
||
|
||
// Second apply with the same id replaces the block instead of duplicating.
|
||
const second = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read first, then edit.' })
|
||
const secondPlan = planFor(second, { homeDir: fx.home, cwd: fx.project })
|
||
const secondRec = await runAction(secondPlan!, fx.actionsDir)
|
||
|
||
body = await readFile(claudeMd, 'utf-8')
|
||
expect(body.match(/codeburn:begin read-edit-ratio/g)).toHaveLength(1)
|
||
expect(body).toContain('Read first, then edit.')
|
||
expect(body).not.toContain('Read before editing.')
|
||
|
||
await undoAction({ id: secondRec.id }, { actionsDir: fx.actionsDir })
|
||
await undoAction({ id: firstRec.id }, { actionsDir: fx.actionsDir })
|
||
expect(await readFile(claudeMd, 'utf-8')).toBe(original)
|
||
})
|
||
})
|
||
|
||
describe('shell-config plan', () => {
|
||
it('writes the bash cap inside # markers to the rc chosen from $SHELL', async () => {
|
||
const fx = await makeFixture()
|
||
const finding = makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' })
|
||
const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' })
|
||
expect(plan!.changes[0]!.path).toBe(join(fx.home, '.zshrc'))
|
||
|
||
await runAction(plan!, fx.actionsDir)
|
||
const body = await readFile(join(fx.home, '.zshrc'), 'utf-8')
|
||
expect(body).toBe('# codeburn:begin bash-output-cap\nexport BASH_MAX_OUTPUT_LENGTH=15000\n# codeburn:end bash-output-cap\n')
|
||
})
|
||
})
|
||
|
||
describe('dry-run', () => {
|
||
it('leaves the fixture tree byte-identical when only planning', async () => {
|
||
const fx = await makeFixture()
|
||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ mcpServers: { s: { command: 'c' } } }, null, 2) + '\n')
|
||
await mkdir(join(fx.home, '.claude', 'skills', 'ghost'), { recursive: true })
|
||
await writeFile(join(fx.home, '.claude', 'skills', 'ghost', 'SKILL.md'), 'x')
|
||
await writeFile(join(fx.project, 'CLAUDE.md'), '# rules\n')
|
||
|
||
const findings: WasteFinding[] = [
|
||
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }),
|
||
makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['ghost'] }),
|
||
makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }),
|
||
makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }),
|
||
]
|
||
|
||
const before = await hashTree(fx.root)
|
||
const plans = planFindings(findings, { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' })
|
||
// Exercise the exact rendering the dry-run path prints.
|
||
renderApplyList(plans.filter(p => p.plan !== null), plans.filter(p => p.plan === null), 0.000002)
|
||
const after = await hashTree(fx.root)
|
||
|
||
expect(plans.every(p => p.plan !== null)).toBe(true)
|
||
expect(after).toBe(before)
|
||
})
|
||
})
|
||
|
||
describe('finding-id regression guard', () => {
|
||
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
||
const KNOWN: ReadonlySet<FindingId> = new Set<FindingId>([
|
||
'read-edit-ratio', 'build-folder-reads', 'redundant-rereads', 'warmup-heavy',
|
||
'unused-mcp', 'mcp-low-coverage', 'mcp-project-scope', 'retry-heavy-capabilities',
|
||
'low-worth-sessions', 'context-heavy-sessions', 'cost-outliers', 'claude-md-too-long',
|
||
'bash-output-cap', 'unused-agents', 'unused-skills', 'unused-commands',
|
||
])
|
||
|
||
it('every finding produced by a detector run carries a stable, known, non-empty id', async () => {
|
||
const fx = await makeFixture()
|
||
const bigClaudeMd = '# Rules\n' + Array.from({ length: 260 }, (_, i) => `- rule ${i}`).join('\n') + '\n'
|
||
await writeFile(join(fx.project, 'CLAUDE.md'), bigClaudeMd)
|
||
|
||
function read(file: string, session = 's1'): ToolCall {
|
||
return { name: 'Read', input: { file_path: file }, sessionId: session, project: 'p' }
|
||
}
|
||
const calls: ToolCall[] = [
|
||
read('/p/node_modules/a.js'), read('/p/node_modules/b.js'), read('/p/dist/c.js'),
|
||
...Array.from({ length: 6 }, () => read('/p/src/app.ts')),
|
||
...Array.from({ length: 10 }, (): ToolCall => ({ name: 'Edit', input: {}, sessionId: 's1', project: 'p' })),
|
||
]
|
||
const coverage: McpServerCoverage[] = [{
|
||
server: 'x', toolsAvailable: 20, toolsInvoked: 1,
|
||
unusedTools: Array.from({ length: 19 }, (_, i) => `mcp__x__t${i}`),
|
||
invocations: 1, loadedSessions: 3, coverageRatio: 0.05,
|
||
}]
|
||
|
||
const findings = [
|
||
detectLowReadEditRatio(calls),
|
||
detectJunkReads(calls),
|
||
detectDuplicateReads(calls),
|
||
detectBloatedClaudeMd(new Set([fx.project])),
|
||
detectMcpToolCoverage([], coverage),
|
||
].filter((f): f is WasteFinding => f !== null)
|
||
|
||
expect(findings.length).toBeGreaterThanOrEqual(5)
|
||
for (const f of findings) {
|
||
expect(f.id).toBeTruthy()
|
||
expect(f.id).toMatch(KEBAB)
|
||
expect(KNOWN.has(f.id)).toBe(true)
|
||
}
|
||
const ids = findings.map(f => f.id)
|
||
expect(new Set(ids).size).toBe(ids.length)
|
||
})
|
||
})
|
||
|
||
describe('unused-mcp plan', () => {
|
||
it('builds a remove-everywhere plan, including other projects[*] entries', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeJson = join(fx.home, '.claude.json')
|
||
await writeFile(claudeJson, JSON.stringify({
|
||
mcpServers: { u: { command: 'u' } },
|
||
projects: { '/some/other': { mcpServers: { u: { command: 'u' }, keepme: {} } } },
|
||
}, null, 2) + '\n')
|
||
|
||
const finding = makeFinding('unused-mcp', CMD_FIX, { kind: 'mcp-remove', servers: ['u'] })
|
||
const plan = planFor(finding, { homeDir: fx.home, cwd: fx.project })
|
||
expect(plan).not.toBeNull()
|
||
expect(plan!.kind).toBe('mcp-remove')
|
||
|
||
await runAction(plan!, fx.actionsDir)
|
||
const after = JSON.parse(await readFile(claudeJson, 'utf-8'))
|
||
expect(after.mcpServers).toEqual({})
|
||
expect(after.projects['/some/other'].mcpServers).toEqual({ keepme: {} })
|
||
})
|
||
})
|
||
|
||
describe('BOM handling', () => {
|
||
it('parses a config file with a UTF-8 BOM', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeJson = join(fx.home, '.claude.json')
|
||
await writeFile(claudeJson, '' + JSON.stringify({ mcpServers: { b: {} }, keep: 1 }, null, 2) + '\n')
|
||
|
||
const { plan, notes } = planFindings(
|
||
[makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['b'] })],
|
||
{ homeDir: fx.home, cwd: fx.project },
|
||
)[0]!
|
||
expect(notes).toEqual([])
|
||
expect(plan).not.toBeNull()
|
||
const written = JSON.parse(String(plan!.changes[0]!.content))
|
||
expect(written).toEqual({ mcpServers: {}, keep: 1 })
|
||
})
|
||
})
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// runOptimizeApply end-to-end (injected stdio, crafted findings, fixture home)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const ANSI = /\[[0-9;]*m/g
|
||
|
||
type Io = {
|
||
input: PassThrough
|
||
output: Writable
|
||
errorOutput: Writable
|
||
stdout(): string
|
||
stderr(): string
|
||
}
|
||
|
||
function makeIo(answer?: string): Io {
|
||
const input = new PassThrough()
|
||
input.end(answer ?? '')
|
||
const outChunks: Buffer[] = []
|
||
const errChunks: Buffer[] = []
|
||
const output = new Writable({ write(c, _e, cb) { outChunks.push(Buffer.from(c)); cb() } })
|
||
const errorOutput = new Writable({ write(c, _e, cb) { errChunks.push(Buffer.from(c)); cb() } })
|
||
return {
|
||
input,
|
||
output,
|
||
errorOutput,
|
||
stdout: () => Buffer.concat(outChunks).toString('utf-8').replace(ANSI, ''),
|
||
stderr: () => Buffer.concat(errChunks).toString('utf-8').replace(ANSI, ''),
|
||
}
|
||
}
|
||
|
||
function applyOpts(fx: Fixture, io: Io, extra: Partial<ApplyOptions> & { findings: WasteFinding[] }): ApplyOptions {
|
||
const ctx: PlanContext = { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh' }
|
||
return {
|
||
ctx,
|
||
actionsDir: fx.actionsDir,
|
||
input: io.input,
|
||
output: io.output,
|
||
errorOutput: io.errorOutput,
|
||
...extra,
|
||
}
|
||
}
|
||
|
||
// One mcp server, one skill, one shell cap: three appliable findings in a
|
||
// stable 1/2/3 order for the picker tests.
|
||
async function threeFindingFixture(): Promise<{ fx: Fixture; findings: WasteFinding[] }> {
|
||
const fx = await makeFixture()
|
||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ mcpServers: { a: { command: 'a' } } }, null, 2) + '\n')
|
||
await mkdir(join(fx.home, '.claude', 'skills', 'foo'), { recursive: true })
|
||
await writeFile(join(fx.home, '.claude', 'skills', 'foo', 'SKILL.md'), 'x')
|
||
const findings: WasteFinding[] = [
|
||
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['a'] }),
|
||
makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }),
|
||
makeFinding('bash-output-cap', { type: 'paste', destination: 'shell-config', label: '', text: 'export BASH_MAX_OUTPUT_LENGTH=15000' }),
|
||
]
|
||
return { fx, findings }
|
||
}
|
||
|
||
describe('runOptimizeApply end-to-end', () => {
|
||
it('--yes applies every plan and prints journal short ids with the undo hint', async () => {
|
||
const { fx, findings } = await threeFindingFixture()
|
||
const io = makeIo()
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true }))
|
||
|
||
const records = await readRecords(fx.actionsDir)
|
||
expect(records).toHaveLength(3)
|
||
const out = io.stdout()
|
||
for (const rec of records) {
|
||
expect(out).toContain(`Applied ${shortId(rec.id)}`)
|
||
expect(out).toContain(`Undo anytime: codeburn act undo ${shortId(rec.id)}`)
|
||
}
|
||
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({})
|
||
expect(existsSync(join(fx.home, '.claude', 'skills', '.archived', 'foo'))).toBe(true)
|
||
expect(existsSync(join(fx.home, '.zshrc'))).toBe(true)
|
||
})
|
||
|
||
it('interactive pick "2" applies only the second plan', async () => {
|
||
const { fx, findings } = await threeFindingFixture()
|
||
const io = makeIo('2\n')
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
|
||
|
||
const records = await readRecords(fx.actionsDir)
|
||
expect(records).toHaveLength(1)
|
||
expect(records[0]!.kind).toBe('archive-skill')
|
||
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({ a: { command: 'a' } })
|
||
expect(existsSync(join(fx.home, '.zshrc'))).toBe(false)
|
||
})
|
||
|
||
it('interactive pick "1,3" applies the first and third plans', async () => {
|
||
const { fx, findings } = await threeFindingFixture()
|
||
const io = makeIo('1,3\n')
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
|
||
|
||
const records = await readRecords(fx.actionsDir)
|
||
expect(records.map(r => r.kind).sort()).toEqual(['mcp-remove', 'shell-config'])
|
||
expect(existsSync(join(fx.home, '.claude', 'skills', 'foo'))).toBe(true)
|
||
})
|
||
|
||
it('a garbage answer applies nothing and prints the empty outcome', async () => {
|
||
const { fx, findings } = await threeFindingFixture()
|
||
const io = makeIo('wat\n')
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
|
||
|
||
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
|
||
expect(io.stdout()).toContain('Nothing applied.')
|
||
})
|
||
|
||
it('EOF at the prompt prints "Nothing applied." and leaves the exit code untouched', async () => {
|
||
const { fx, findings } = await threeFindingFixture()
|
||
const io = makeIo()
|
||
const prevExit = process.exitCode
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
|
||
|
||
expect(process.exitCode).toBe(prevExit)
|
||
expect(io.stdout()).toContain('Nothing applied.')
|
||
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
|
||
})
|
||
|
||
it('--only restricts the applied set', async () => {
|
||
const { fx, findings } = await threeFindingFixture()
|
||
const io = makeIo()
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true, only: 'unused-skills' }))
|
||
|
||
const records = await readRecords(fx.actionsDir)
|
||
expect(records).toHaveLength(1)
|
||
expect(records[0]!.kind).toBe('archive-skill')
|
||
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({ a: { command: 'a' } })
|
||
})
|
||
|
||
it('--only with an unknown or not-appliable id errors with the valid ids and exit code 2', async () => {
|
||
const { fx, findings } = await threeFindingFixture()
|
||
const io = makeIo()
|
||
const prevExit = process.exitCode
|
||
try {
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, yes: true, only: 'read-edit-ratio' }))
|
||
|
||
expect(process.exitCode).toBe(2)
|
||
const err = io.stderr()
|
||
expect(err).toContain('read-edit-ratio')
|
||
expect(err).toContain('Appliable ids for this run:')
|
||
expect(err).toContain('mcp-low-coverage')
|
||
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
|
||
expect(io.stdout()).not.toContain('No appliable config-class fixes')
|
||
} finally {
|
||
process.exitCode = prevExit
|
||
}
|
||
})
|
||
|
||
it('--yes skips claude-md plans with a reason unless explicitly selected via --only', async () => {
|
||
const { fx, findings } = await threeFindingFixture()
|
||
const claudeMdFinding = makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'Read first.' })
|
||
const io = makeIo()
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [claudeMdFinding, ...findings], yes: true }))
|
||
|
||
expect(io.stdout()).toContain('Skipped read-edit-ratio: CLAUDE.md edits are not applied with --yes')
|
||
expect(existsSync(join(fx.project, 'CLAUDE.md'))).toBe(false)
|
||
expect((await readRecords(fx.actionsDir)).map(r => r.kind)).not.toContain('claude-md-rule')
|
||
|
||
const fx2 = await makeFixture()
|
||
const io2 = makeIo()
|
||
await runOptimizeApply([], undefined, applyOpts(fx2, io2, { findings: [claudeMdFinding], yes: true, only: 'read-edit-ratio' }))
|
||
|
||
expect((await readRecords(fx2.actionsDir)).map(r => r.kind)).toEqual(['claude-md-rule'])
|
||
expect(await readFile(join(fx2.project, 'CLAUDE.md'), 'utf-8')).toContain('<!-- codeburn:begin read-edit-ratio -->')
|
||
})
|
||
|
||
it('project-scope leaves unrelated projects[*] entries untouched and previews the cold removals', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeJson = join(fx.home, '.claude.json')
|
||
const serverValue = { command: 'srv' }
|
||
await writeFile(claudeJson, JSON.stringify({
|
||
mcpServers: { srv: serverValue },
|
||
projects: {
|
||
'/cold/one': { mcpServers: { srv: serverValue } },
|
||
'/unrelated/proj': { mcpServers: { srv: serverValue, keepme: {} } },
|
||
},
|
||
}, null, 2) + '\n')
|
||
const keeper = join(fx.root, 'keeper')
|
||
await mkdir(keeper, { recursive: true })
|
||
|
||
const finding = makeFinding('mcp-project-scope', CMD_FIX, {
|
||
kind: 'mcp-project-scope',
|
||
servers: [{ server: 'srv', keepProjects: [keeper], removeProjects: ['/cold/one'] }],
|
||
})
|
||
const io = makeIo()
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true }))
|
||
|
||
expect(io.stdout()).toContain('(removes srv from 1 project entry: /cold/one)')
|
||
|
||
const after = JSON.parse(await readFile(claudeJson, 'utf-8'))
|
||
expect(after.mcpServers).toEqual({})
|
||
expect(after.projects['/cold/one'].mcpServers).toEqual({})
|
||
expect(after.projects['/unrelated/proj'].mcpServers).toEqual({ srv: serverValue, keepme: {} })
|
||
expect(JSON.parse(await readFile(join(keeper, '.mcp.json'), 'utf-8')).mcpServers).toEqual({ srv: serverValue })
|
||
})
|
||
|
||
it('surfaces parse-error notes when every plan resolves to null', async () => {
|
||
const fx = await makeFixture()
|
||
await writeFile(join(fx.home, '.claude.json'), 'not json{{{')
|
||
const finding = makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['ghost'] })
|
||
const io = makeIo()
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true }))
|
||
|
||
const out = io.stdout()
|
||
expect(out).toContain('No appliable config-class fixes')
|
||
expect(out).toContain('could not parse')
|
||
expect(out).toContain('.claude.json')
|
||
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
|
||
})
|
||
|
||
it('renders notes under manual findings alongside appliable ones', async () => {
|
||
const fx = await makeFixture()
|
||
await writeFile(join(fx.home, '.claude.json'), 'not json{{{')
|
||
await mkdir(join(fx.home, '.claude', 'skills', 'foo'), { recursive: true })
|
||
await writeFile(join(fx.home, '.claude', 'skills', 'foo', 'SKILL.md'), 'x')
|
||
const findings: WasteFinding[] = [
|
||
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['ghost'] }),
|
||
makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['foo'] }),
|
||
]
|
||
const io = makeIo()
|
||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings, dryRun: true }))
|
||
|
||
const out = io.stdout()
|
||
expect(out).toContain('[mcp-low-coverage] manual')
|
||
expect(out).toContain('could not parse')
|
||
})
|
||
})
|
||
|
||
describe('stale-plan detection', () => {
|
||
it('rejects when the target changed after the plan was built, leaving nothing behind', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeJson = join(fx.home, '.claude.json')
|
||
await writeFile(claudeJson, JSON.stringify({ mcpServers: { s: {} } }, null, 2) + '\n')
|
||
const plan = planFor(
|
||
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }),
|
||
{ homeDir: fx.home, cwd: fx.project },
|
||
)
|
||
expect(plan).not.toBeNull()
|
||
|
||
const interim = JSON.stringify({ mcpServers: { s: {}, addedMeanwhile: {} } }, null, 2) + '\n'
|
||
await writeFile(claudeJson, interim)
|
||
|
||
await expect(runAction(plan!, fx.actionsDir)).rejects.toThrow(/changed since the plan was built; re-run codeburn optimize --apply/)
|
||
expect(await readFile(claudeJson, 'utf-8')).toBe(interim)
|
||
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
|
||
const backups = await readdir(join(fx.actionsDir, 'backups')).catch(() => [])
|
||
expect(backups).toEqual([])
|
||
})
|
||
|
||
it('rejects a plan expecting an absent file when the file appeared', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeMd = join(fx.project, 'CLAUDE.md')
|
||
const plan = planFor(
|
||
makeFinding('read-edit-ratio', { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }),
|
||
{ homeDir: fx.home, cwd: fx.project },
|
||
)
|
||
expect(plan!.changes[0]).toMatchObject({ op: 'create', expectedHash: null })
|
||
|
||
await writeFile(claudeMd, '# appeared meanwhile\n')
|
||
|
||
await expect(runAction(plan!, fx.actionsDir)).rejects.toThrow(/changed since the plan was built/)
|
||
expect(await readFile(claudeMd, 'utf-8')).toBe('# appeared meanwhile\n')
|
||
expect(await readRecords(fx.actionsDir)).toHaveLength(0)
|
||
})
|
||
|
||
it('applies when the target still matches the expected hash', async () => {
|
||
const fx = await makeFixture()
|
||
const claudeJson = join(fx.home, '.claude.json')
|
||
await writeFile(claudeJson, JSON.stringify({ mcpServers: { s: {} } }, null, 2) + '\n')
|
||
const plan = planFor(
|
||
makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['s'] }),
|
||
{ homeDir: fx.home, cwd: fx.project },
|
||
)
|
||
expect(plan!.changes[0]!.op === 'move' ? undefined : plan!.changes[0]!.expectedHash).toMatch(/^[0-9a-f]{64}$/)
|
||
|
||
const rec = await runAction(plan!, fx.actionsDir)
|
||
expect(rec.status).toBe('applied')
|
||
expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({})
|
||
})
|
||
|
||
it('a change without expectedHash skips validation (framework back-compat)', async () => {
|
||
const fx = await makeFixture()
|
||
const p = join(fx.home, 'free.txt')
|
||
await writeFile(p, 'anything at all')
|
||
|
||
const rec = await runAction({
|
||
kind: 'claude-md-rule',
|
||
description: 'no expected hash',
|
||
changes: [{ op: 'edit', path: p, content: 'overwritten' }],
|
||
}, fx.actionsDir)
|
||
expect(rec.status).toBe('applied')
|
||
expect(await readFile(p, 'utf-8')).toBe('overwritten')
|
||
})
|
||
})
|