@progen-labs/spectra 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,8 @@ and per-component render/change-detection time; source files too, where the fram
14
14
  functions. A [Progen Labs](https://github.com/progen-labs) project — open source, non-profit, no
15
15
  telemetry, no account required.
16
16
 
17
+ **Watch the two-minute demo:** [progen-labs.github.io/website/#spectra/demo](https://progen-labs.github.io/website/#spectra/demo).
18
+
17
19
  **Documentation:** the full guide, with annotated screenshots of every screen, is at
18
20
  [progen-labs.github.io/website](https://progen-labs.github.io/website/#spectra/docs/introduction).
19
21
 
@@ -210,6 +212,37 @@ Tools: `profile_page`, `verify_change` (re-profile and compare against a baselin
210
212
  `--allow-remote` to lift that), runs one browser at a time, and stops Chrome and any dev server it started when a
211
213
  call is cancelled. See the [MCP guide](https://progen-labs.github.io/website/#spectra/docs/mcp-server).
212
214
 
215
+ ## The AI fix harness
216
+
217
+ `spectra fix` hands the costliest findings in your own code to a coding agent, one at a time, and keeps a change only
218
+ when your checks still pass and Spectra measures the page faster beyond run-to-run noise. Kept changes arrive as
219
+ commits on a new branch, `spectra/fix-<time>`, for you to review; everything else is undone.
220
+
221
+ ```bash
222
+ npx spectra fix # Claude Code (signed in) fixes up to 3 findings
223
+ npx spectra fix --check "npm run e2e" --max-fixes 5
224
+ npx spectra fix --agent "./my-agent.sh" # any agent: instructions on stdin
225
+ ```
226
+
227
+ The harness, not the agent, makes every decision:
228
+
229
+ - **Isolation.** It works in a git worktree of your last commit, with its own dev server. Your working tree is never
230
+ touched, and nothing is pushed or merged.
231
+ - **Your checks first.** Your test, type-check and build scripts (or `--check`, which can test the running app at
232
+ `$SPECTRA_FIX_URL`) must pass before any change and after each one. A faster page that breaks is undone.
233
+ - **Scope rules.** A change may not touch `package.json`, lockfiles, configuration, tests or CI, delete files, or
234
+ exceed 150 lines.
235
+ - **Proof.** The change is kept only if `spectra compare` says *improved* with medium or high confidence and no new
236
+ critical finding; with low confidence it measures again.
237
+ - **A record.** Each kept commit carries its evidence; `.spectra/fix/<time>/report.md` summarises the session, and a
238
+ history skips findings that failed before. A finding that an earlier kept fix already cleared is skipped too.
239
+
240
+ By default the agent is Claude Code in headless mode, allowed only to read and edit files (no shell, no web, no MCP),
241
+ with a spending cap per finding. A test bench (`npm run bench`) checks the harness's decisions in CI: a real fix is
242
+ kept, a "faster" change that breaks the page is caught, a cosmetic change doesn't count, and an edit to
243
+ `package.json` is refused. With Claude Code on the demo app, it kept two of three fixes and cut JavaScript CPU time
244
+ from 1.51 s to 82.5 ms (−95%) for $0.44. See [the AI fix harness](https://progen-labs.github.io/website/#spectra/docs/harness).
245
+
213
246
  ## In VS Code
214
247
 
215
248
  The Spectra extension (`vscode/`) puts results where you work: every finding in the **Problems panel** at the file
@@ -219,13 +252,15 @@ baseline** after a change, golden paths, and the dashboard inside the editor. Re
219
252
  assistant writes to `.spectra/results` appear automatically, and on VS Code 1.101+ it offers Spectra's MCP server to
220
253
  agent mode. It runs your project's Spectra (0.2.0 or later) or `npx`.
221
254
 
222
- Until it is listed on the VS Code Marketplace and Open VSX, build and install it from this repository:
255
+ Install **Spectra — frontend performance** from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=progen-labs.spectra-performance), or from a terminal:
223
256
 
224
257
  ```bash
225
- cd vscode && npx @vscode/vsce package --no-dependencies
226
- code --install-extension spectra-0.1.0.vsix
258
+ code --install-extension progen-labs.spectra-performance
227
259
  ```
228
260
 
261
+ For Cursor, Windsurf or VSCodium (Open VSX), until it is listed there: `cd vscode && npx @vscode/vsce package
262
+ --no-dependencies`, then install `spectra-performance-0.1.0.vsix` with **Extensions: Install from VSIX…**.
263
+
229
264
  See the [VS Code guide](https://progen-labs.github.io/website/#spectra/docs/vscode).
230
265
 
231
266
  ## Safe by default
package/bin/spectra.js CHANGED
@@ -6,6 +6,7 @@
6
6
  * spectra compare <before> <after> before vs after, noise-aware
7
7
  * spectra reports list saved reports
8
8
  * spectra mcp [--project <dir>] MCP server for AI tools (stdio)
9
+ * spectra fix [options] the AI fix harness: fix, check, verify
9
10
  * spectra dashboard open the dashboard UI
10
11
  * spectra --help show this help
11
12
  * spectra --version print the version
@@ -43,6 +44,10 @@ Usage:
43
44
  spectra mcp [--project <dir>] [--allow-remote]
44
45
  Run as an MCP server (stdio) for Claude Code,
45
46
  Cursor, VS Code and other AI tools
47
+ spectra fix [options] The AI fix harness: an agent fixes the costliest
48
+ findings in a copy of your code; a change is kept
49
+ only if your checks pass and it measures faster
50
+ (spectra fix --help for its options)
46
51
  spectra dashboard Open the Spectra dashboard UI
47
52
  spectra --help, -h Show this help
48
53
  spectra --version, -v Print the version
@@ -95,6 +100,7 @@ Examples:
95
100
  spectra performance --quick --runs 5 --out before.json
96
101
  spectra performance --quick --runs 5 --out after.json
97
102
  spectra compare before.json after.json # did the change help?
103
+ spectra fix --max-fixes 3 # let an agent fix them, verified
98
104
  spectra performance --json | jq .summary # pipe it anywhere
99
105
  `;
100
106
 
@@ -300,6 +306,14 @@ switch (command) {
300
306
  });
301
307
  break;
302
308
  }
309
+ case 'fix':
310
+ // In-process, in the user's project: the harness manages its own copy,
311
+ // dev server and child processes (server/fix/cli.js).
312
+ import('../server/fix/cli.js').catch(err => {
313
+ console.error(`spectra: ${err.message}`);
314
+ process.exit(1);
315
+ });
316
+ break;
303
317
  case 'mcp':
304
318
  // In-process: stdin/stdout are the protocol channel, and the working
305
319
  // directory stays the user's (the project, unless --project or the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@progen-labs/spectra",
3
- "version": "0.3.0",
4
- "description": "Spectra — a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
3
+ "version": "0.4.1",
4
+ "description": "Spectra \u2014 a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "spectra": "bin/spectra.js"
@@ -33,7 +33,8 @@
33
33
  "demo": "vite --config fixtures/demo-app/vite.config.js",
34
34
  "profile": "node server/cli.js",
35
35
  "test": "node --test \"server/**/test-*.js\" \"action/test-*.js\"",
36
- "test:vscode": "npm --prefix vscode test"
36
+ "test:vscode": "npm --prefix vscode test",
37
+ "bench": "node bench/run.mjs"
37
38
  },
38
39
  "engines": {
39
40
  "node": ">=18"
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The coding agent `spectra fix` hands one finding to. By default it is Claude
3
+ * Code in headless mode, allowed only to read and edit files: no shell, no
4
+ * web, no MCP servers. Any other agent works through --agent "<command>": it
5
+ * gets the instructions on stdin (and in $SPECTRA_FIX_PROMPT_FILE) and runs in
6
+ * the copy of the project.
7
+ */
8
+ import { spawn } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ const run = (cmd, args, { cwd, input, env, signal, timeoutMs, shell = false }) =>
14
+ new Promise((resolve, reject) => {
15
+ const child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], shell });
16
+ let out = '';
17
+ let err = '';
18
+ child.stdout.on('data', d => (out += d));
19
+ child.stderr.on('data', d => (err += d));
20
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
21
+ const abort = () => child.kill('SIGTERM');
22
+ signal?.addEventListener('abort', abort, { once: true });
23
+ child.on('error', e => {
24
+ clearTimeout(timer);
25
+ reject(e.code === 'ENOENT' ? new Error(`${cmd} was not found`) : e);
26
+ });
27
+ child.on('close', code => {
28
+ clearTimeout(timer);
29
+ signal?.removeEventListener('abort', abort);
30
+ resolve({ code, out, err });
31
+ });
32
+ child.stdin.end(input ?? '');
33
+ });
34
+
35
+ /** Claude Code, headless. */
36
+ export function claudeAgent({ bin = process.env.SPECTRA_CLAUDE || 'claude', model, budgetUsd = 2, timeoutMs = 10 * 60_000 } = {}) {
37
+ return {
38
+ name: `Claude Code${model ? ` (${model})` : ''}`,
39
+ async run(prompt, { cwd, signal }) {
40
+ const args = [
41
+ '-p',
42
+ prompt,
43
+ '--output-format',
44
+ 'json',
45
+ '--permission-mode',
46
+ 'acceptEdits',
47
+ '--allowedTools',
48
+ 'Read Edit Write Glob Grep',
49
+ '--disallowedTools',
50
+ 'Bash WebFetch WebSearch',
51
+ '--strict-mcp-config',
52
+ '--no-session-persistence',
53
+ '--max-budget-usd',
54
+ String(budgetUsd),
55
+ ...(model ? ['--model', model] : []),
56
+ ];
57
+ let res;
58
+ try {
59
+ res = await run(bin, args, { cwd, signal, timeoutMs });
60
+ } catch (err) {
61
+ throw new Error(`could not start Claude Code (${err.message}). Install it (https://claude.com/claude-code), set SPECTRA_CLAUDE to its path, or pass --agent "<command>".`);
62
+ }
63
+ let parsed = null;
64
+ try {
65
+ parsed = JSON.parse(res.out.trim().split('\n').pop());
66
+ } catch {
67
+ /* not JSON */
68
+ }
69
+ if (res.code !== 0 && !parsed) throw new Error(`Claude Code exited with code ${res.code}: ${(res.err || res.out).trim().split('\n').slice(-3).join(' ')}`);
70
+ return {
71
+ summary: String(parsed?.result ?? res.out).trim().split('\n').filter(Boolean).pop()?.slice(0, 300) || '',
72
+ costUsd: parsed?.total_cost_usd ?? null,
73
+ turns: parsed?.num_turns ?? null,
74
+ error: parsed?.is_error ? String(parsed.result || 'agent error') : null,
75
+ };
76
+ },
77
+ };
78
+ }
79
+
80
+ /** Any agent, as a shell command run in the copy of the project. */
81
+ export function commandAgent(command, { timeoutMs = 10 * 60_000 } = {}) {
82
+ return {
83
+ name: command,
84
+ async run(prompt, { cwd, signal, finding }) {
85
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-fix-')), 'prompt.md');
86
+ fs.writeFileSync(file, prompt);
87
+ try {
88
+ const res = await run(command, [], { cwd, input: prompt, signal, timeoutMs, shell: true, env: { SPECTRA_FIX_PROMPT_FILE: file, SPECTRA_FIX_FINDING: JSON.stringify(finding) } });
89
+ if (res.code !== 0) throw new Error(`the agent exited with code ${res.code}: ${(res.err || res.out).trim().split('\n').slice(-2).join(' ')}`);
90
+ return { summary: res.out.trim().split('\n').filter(Boolean).pop()?.slice(0, 300) || '', costUsd: null, turns: null, error: null };
91
+ } finally {
92
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
93
+ }
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The project's own checks: the tests, type check and build that must pass
3
+ * after every change, so a "faster" page is never a broken one. A check can
4
+ * test the live app at $SPECTRA_FIX_URL.
5
+ */
6
+ import { spawn } from 'node:child_process';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ const PLACEHOLDER = /no test specified/;
11
+
12
+ /** Checks from package.json scripts: test, typecheck and build. */
13
+ export function detectChecks(projectDir) {
14
+ let scripts = {};
15
+ try {
16
+ scripts = JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8')).scripts || {};
17
+ } catch {
18
+ /* no package.json */
19
+ }
20
+ const checks = [];
21
+ if (scripts.test && !PLACEHOLDER.test(scripts.test)) checks.push({ name: 'npm test', cmd: 'npm test' });
22
+ const typecheck = ['typecheck', 'type-check', 'check-types', 'tsc'].find(s => scripts[s]);
23
+ if (typecheck) checks.push({ name: `npm run ${typecheck}`, cmd: `npm run ${typecheck}` });
24
+ if (scripts.build) checks.push({ name: 'npm run build', cmd: 'npm run build' });
25
+ return checks;
26
+ }
27
+
28
+ /** Run checks in order, stopping at the first failure. */
29
+ export async function runChecks(checks, { cwd, env = {}, signal, timeoutMs = 10 * 60_000 }) {
30
+ const results = [];
31
+ for (const check of checks) {
32
+ const t0 = Date.now();
33
+ const res = await new Promise(resolve => {
34
+ const child = spawn(check.cmd, { cwd, shell: true, env: { ...process.env, CI: '1', FORCE_COLOR: '0', ...env }, stdio: ['ignore', 'pipe', 'pipe'] });
35
+ let out = '';
36
+ child.stdout.on('data', d => (out = (out + d).slice(-4000)));
37
+ child.stderr.on('data', d => (out = (out + d).slice(-4000)));
38
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
39
+ const abort = () => child.kill('SIGTERM');
40
+ signal?.addEventListener('abort', abort, { once: true });
41
+ child.on('error', e => resolve({ code: 127, out: e.message }));
42
+ child.on('close', code => {
43
+ clearTimeout(timer);
44
+ signal?.removeEventListener('abort', abort);
45
+ resolve({ code, out });
46
+ });
47
+ });
48
+ const result = { name: check.name, ok: res.code === 0, ms: Date.now() - t0, tail: res.out.trim().split('\n').slice(-12).join('\n') };
49
+ results.push(result);
50
+ if (!result.ok) break;
51
+ }
52
+ return results;
53
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * spectra fix — the AI fix harness. It runs in the user's project: profiles a
3
+ * copy of the last commit, hands the costliest findings in the project's own
4
+ * code to a coding agent one at a time, and keeps a change only when the
5
+ * project's checks pass and Spectra measures it faster beyond run-to-run
6
+ * noise. Kept changes are commits on a new branch; nothing else is touched.
7
+ */
8
+ import { spawn, spawnSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { intOption, parseArgs } from '../args.js';
13
+ import { bootDevServer } from '../explore/boot.js';
14
+ import { SPECTRA_VERSION } from '../output.js';
15
+ import { claudeAgent, commandAgent } from './agent.js';
16
+ import { detectChecks, runChecks } from './checks.js';
17
+ import { runFix } from './harness.js';
18
+ import { reportMarkdown } from './report.js';
19
+ import { changesIn, commitChange, commitsOn, createWorkspace, diffOf, ignoreSpectra, removeWorkspace, repoOf, uncommitted, undoChanges } from './workspace.js';
20
+
21
+ const BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin/spectra.js');
22
+ const argv = process.argv.slice(3);
23
+ const { opt, flag } = parseArgs(argv, { values: ['runs', 'max-fixes', 'agent', 'model', 'max-budget-usd', 'check', 'command', 'boot-timeout', 'branch', 'out', 'max-lines'] });
24
+ const log = line => console.error(`spectra: ${line}`);
25
+ const json = flag('json');
26
+
27
+ const HELP = `usage: spectra fix [options]
28
+
29
+ The AI fix harness. In an isolated copy of your last commit, it hands the
30
+ costliest findings in your own code to a coding agent one at a time, then
31
+ keeps a change only if your checks pass and Spectra measures it faster
32
+ beyond run-to-run noise. Kept changes are commits on a new branch.
33
+
34
+ --max-fixes <n> Findings to attempt (default 3)
35
+ --runs <n> Page loads per measurement (default 3)
36
+ --check "<cmd>" A check that must pass after every change (repeat it
37
+ for several). Default: your test, typecheck and
38
+ build scripts. Checks can test the running app at
39
+ $SPECTRA_FIX_URL.
40
+ --no-checks Rely on measurements alone (not recommended)
41
+ --agent "<cmd>" Your own agent: gets the instructions on stdin and in
42
+ $SPECTRA_FIX_PROMPT_FILE, runs in the copy
43
+ --model <name> Model for Claude Code, the default agent
44
+ --max-budget-usd <n> Claude Code's spending cap per finding (default 2)
45
+ --command "<cmd>" The dev-server command (default: your npm dev script)
46
+ --boot-timeout <s> How long to wait for the dev server (default 60)
47
+ --install Install dependencies in the copy instead of linking yours
48
+ --branch <name> Branch for the fixes (default spectra/fix-<time>)
49
+ --max-lines <n> Largest change accepted (default 150 lines)
50
+ --retry Try findings that failed in an earlier session
51
+ --keep-worktree Keep the copy in .spectra/fix/ for inspection
52
+ --json, --out <file> Machine-readable session report`;
53
+
54
+ if (flag('help') || flag('h')) {
55
+ console.log(HELP);
56
+ process.exit(0);
57
+ }
58
+
59
+ const fail = (message, code = 1) => {
60
+ console.error(`spectra: ${message}`);
61
+ process.exit(code);
62
+ };
63
+
64
+ const checksFrom = () => {
65
+ const list = [];
66
+ for (let i = 0; i < argv.length; i++) if (argv[i] === '--check' && argv[i + 1]) list.push({ name: argv[i + 1], cmd: argv[++i] });
67
+ return list;
68
+ };
69
+
70
+ let runs;
71
+ let maxFixes;
72
+ let maxLines;
73
+ try {
74
+ runs = intOption(opt('runs'), 'runs', { min: 1, max: 10, fallback: 3 });
75
+ maxFixes = intOption(opt('max-fixes'), 'max-fixes', { min: 1, max: 20, fallback: 3 });
76
+ maxLines = intOption(opt('max-lines'), 'max-lines', { min: 1, max: 5000, fallback: 150 });
77
+ } catch (err) {
78
+ fail(err.message);
79
+ }
80
+ if (opt('url')) fail('spectra fix starts its own dev server in a copy of your project, so it does not take --url. Use --command if the dev server needs a special command.');
81
+
82
+ const projectDir = process.cwd();
83
+ let repo;
84
+ try {
85
+ repo = repoOf(projectDir);
86
+ } catch (err) {
87
+ fail(err.message);
88
+ }
89
+ const dirty = uncommitted(repo.root);
90
+ if (dirty.length)
91
+ fail(`commit or stash your changes first: spectra fix works on a copy of your last commit, and these would not be in it:\n ${dirty.slice(0, 6).join('\n ')}${dirty.length > 6 ? '\n …' : ''}`);
92
+
93
+ let checks = checksFrom();
94
+ if (!checks.length && !flag('no-checks')) checks = detectChecks(projectDir);
95
+ if (!checks.length && !flag('no-checks'))
96
+ fail('no checks to run. A faster page must not be a broken one, so spectra fix needs at least one check that proves the app still works: add a test script, pass --check "<cmd>" (it can test the running app at $SPECTRA_FIX_URL), or pass --no-checks to rely on measurements alone.');
97
+ if (flag('no-checks')) checks = [];
98
+
99
+ const agent = opt('agent')
100
+ ? commandAgent(String(opt('agent')))
101
+ : claudeAgent({ model: opt('model') === true ? undefined : opt('model'), budgetUsd: Number(opt('max-budget-usd') ?? 2) });
102
+
103
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
104
+ const sessionRoot = path.join(projectDir, '.spectra', 'fix');
105
+ const sessionDir = path.join(sessionRoot, stamp);
106
+ const historyFile = path.join(sessionRoot, 'history.json');
107
+ let branch = typeof opt('branch') === 'string' ? opt('branch') : `spectra/fix-${stamp}`;
108
+ fs.mkdirSync(path.join(sessionDir, 'results'), { recursive: true });
109
+ ignoreSpectra(repo.root);
110
+
111
+ const controller = new AbortController();
112
+ let ws = null;
113
+ let server = null;
114
+ let child = null;
115
+ let session = null;
116
+
117
+ async function cleanup() {
118
+ await server?.stop().catch(() => {});
119
+ if (ws && !flag('keep-worktree')) removeWorkspace(repo.root, ws, { keepBranch: commitsOn(repo.root, ws.branch) > 0 });
120
+ }
121
+ for (const signal of ['SIGINT', 'SIGTERM']) {
122
+ process.on(signal, () => {
123
+ controller.abort();
124
+ child?.kill('SIGTERM');
125
+ log('stopping …');
126
+ cleanup().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
127
+ });
128
+ }
129
+
130
+ /** `spectra <args> --json` in the copy; resolves with the parsed result. */
131
+ function spectra(args) {
132
+ return new Promise((resolve, reject) => {
133
+ child = spawn(process.execPath, [BIN, ...args, '--json'], {
134
+ cwd: ws.projectDir,
135
+ env: { ...process.env, PERF_FRONTEND_DIR: ws.projectDir, PERF_REPORTS_DIR: path.join(sessionDir, 'reports') },
136
+ stdio: ['ignore', 'pipe', 'pipe'],
137
+ });
138
+ let out = '';
139
+ let err = '';
140
+ child.stdout.on('data', d => (out += d));
141
+ child.stderr.on('data', d => {
142
+ err += d;
143
+ for (const line of String(d).split('\n')) if (/^spectra: run \d+\/\d+/.test(line)) console.error(` ${line.replace(/^spectra:\s*/, '')}`);
144
+ });
145
+ child.on('error', reject);
146
+ child.on('close', code => {
147
+ if (code === 1) return reject(new Error(err.trim().split('\n').filter(l => l.startsWith('spectra:')).pop()?.replace(/^spectra:\s*/, '') || `spectra ${args[0]} failed`));
148
+ try {
149
+ resolve(JSON.parse(out));
150
+ } catch {
151
+ reject(new Error(`spectra ${args[0]} printed no result`));
152
+ }
153
+ });
154
+ });
155
+ }
156
+
157
+ const slug = s => s.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
158
+
159
+ try {
160
+ if (branchExists(branch)) throw new Error(`branch ${branch} already exists; pass --branch <name>.`);
161
+ log(`working in a copy of ${shortHead()} on branch ${branch} …`);
162
+ ws = createWorkspace({ root: repo.root, sub: repo.sub, dir: path.join(sessionDir, 'worktree'), branch, install: flag('install'), log });
163
+ ws.exists = file => fs.existsSync(path.join(ws.projectDir, file));
164
+ ws.changes = () => changesIn(ws);
165
+ ws.diff = () => diffOf(ws);
166
+ ws.commit = (files, message) => commitChange(ws, files, message);
167
+ ws.undo = () => undoChanges(ws);
168
+
169
+ const command = typeof opt('command') === 'string' ? opt('command') : undefined;
170
+ log(`starting the dev server in the copy${command ? ` (${command})` : ''} …`);
171
+ server = await bootDevServer({ root: ws.projectDir, command, timeoutMs: (Number(opt('boot-timeout')) || 60) * 1000 });
172
+ log(`dev server ready at ${server.url}`);
173
+
174
+ let history = {};
175
+ try {
176
+ history = JSON.parse(fs.readFileSync(historyFile, 'utf8'));
177
+ } catch {
178
+ /* first session */
179
+ }
180
+
181
+ session = await runFix({
182
+ agent,
183
+ workspace: ws,
184
+ checks,
185
+ history,
186
+ retry: flag('retry'),
187
+ maxFixes,
188
+ maxLines,
189
+ url: server.url,
190
+ signal: controller.signal,
191
+ log,
192
+ runChecks: () => runChecks(checks, { cwd: ws.projectDir, env: { SPECTRA_FIX_URL: server.url }, signal: controller.signal }),
193
+ profile: async label => {
194
+ const file = path.join(sessionDir, 'results', `${slug(label)}.json`);
195
+ const result = await spectra(['performance', '--quick', '--runs', String(runs), '--url', server.url, '--out', file]);
196
+ return { file, result };
197
+ },
198
+ compare: async (before, after) => (await spectra(['compare', before.join(','), after.join(',')])).comparison,
199
+ });
200
+ Object.assign(session, { kind: 'fix', spectraVersion: SPECTRA_VERSION, project: projectDir, branch, runs, startedAt: stamp, sessionDir });
201
+
202
+ for (const a of session.attempts) history[a.finding.key] = { title: a.finding.title, outcome: a.outcome, at: new Date().toISOString() };
203
+ fs.writeFileSync(historyFile, `${JSON.stringify(history, null, 2)}\n`);
204
+ } catch (err) {
205
+ console.error(`spectra: ${err.message}`);
206
+ if (err.tail) console.error(err.tail.replace(/^/gm, ' | '));
207
+ if (/cannot work/.test(err.message) && !opt('agent'))
208
+ console.error('spectra: sign in to Claude Code first (run `claude` once), set SPECTRA_CLAUDE to its path, or pass --agent "<command>".');
209
+ await cleanup();
210
+ process.exit(1);
211
+ }
212
+
213
+ await cleanup();
214
+ const md = reportMarkdown(session);
215
+ fs.writeFileSync(path.join(sessionDir, 'report.md'), md);
216
+ fs.writeFileSync(path.join(sessionDir, 'report.json'), `${JSON.stringify(session, null, 2)}\n`);
217
+ if (opt('out') && typeof opt('out') === 'string') fs.writeFileSync(path.resolve(opt('out')), `${JSON.stringify(session, null, 2)}\n`);
218
+
219
+ const rel = p => path.relative(projectDir, p) || '.';
220
+ console.error('');
221
+ console.error(`Spectra fix — ${session.kept} of ${session.attempts.length} finding${session.attempts.length === 1 ? '' : 's'} fixed`);
222
+ for (const a of session.attempts) console.error(` ${a.outcome === 'kept' ? '✓ kept ' : '✗ undone '} ${a.finding.title} (${a.finding.file}${a.finding.line ? `:${a.finding.line}` : ''})${a.outcome === 'kept' ? '' : ` — ${a.reason}`}`);
223
+ if (session.final) {
224
+ console.error(`\nOverall: ${session.final.verdict.toUpperCase()} (confidence: ${session.final.confidence})`);
225
+ for (const h of session.final.headlines) console.error(` ${h.kind === 'improved' ? '✓' : '✗'} ${h.text}`);
226
+ }
227
+ if (session.kept) console.error(`\nThe fixes are on branch ${branch}. Review them with:\n git log -p HEAD..${branch}\nthen merge the branch or open a pull request.`);
228
+ console.error(`\nReport: ${rel(path.join(sessionDir, 'report.md'))}`);
229
+ if (json) process.stdout.write(`${JSON.stringify(session)}\n`);
230
+ process.exit(0);
231
+
232
+ function branchExists(name) {
233
+ return spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { cwd: repo.root }).status === 0;
234
+ }
235
+ function shortHead() {
236
+ return spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repo.root, encoding: 'utf8' }).stdout.trim();
237
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The loop of `spectra fix`: measure, hand one finding to an agent, check its
3
+ * change, measure again, and keep the change only if it measurably helped.
4
+ * Everything it touches is passed in (profile, compare, agent, workspace,
5
+ * checks), so the loop itself is tested without a browser or a model.
6
+ */
7
+ import { findingKey } from '../../shared/compare.js';
8
+ import { fixPrompt, judge, rankFindings, scopeProblem } from './policy.js';
9
+
10
+ // agent errors no other finding would get past: stop instead of trying them all
11
+ const FATAL = /not logged in|\/login|authenticat|api key|credit balance|was not found|could not start/i;
12
+
13
+ /**
14
+ * @param {object} o
15
+ * @param {(label: string) => Promise<{file: string, result: object}>} o.profile
16
+ * @param {(before: string[], after: string[]) => Promise<object>} o.compare spectra compare's comparison
17
+ * @param {{name: string, run: Function}} o.agent
18
+ * @param {object} o.workspace { projectDir, exists(file), changes(), diff(), commit(files, message), undo() }
19
+ * @param {() => Promise<{name: string, ok: boolean}[]>} o.runChecks
20
+ * @param {{name: string}[]} o.checks
21
+ * @returns {Promise<object>} the session: baseline, attempts, overall comparison
22
+ */
23
+ export async function runFix(o) {
24
+ const { profile, compare, agent, workspace: ws, runChecks, checks = [], log = () => {}, maxFixes = 3, maxLines = 150, history = {}, retry = false, url, signal } = o;
25
+ const session = { agent: agent.name, checks: checks.map(c => c.name), attempts: [], skipped: [], baseline: null, final: null, kept: 0 };
26
+
27
+ if (checks.length) {
28
+ log(`running the checks before any change (${checks.map(c => c.name).join(', ')}) …`);
29
+ const before = await runChecks();
30
+ const failed = before.find(r => !r.ok);
31
+ if (failed) throw Object.assign(new Error(`"${failed.name}" fails before any change, so it cannot tell a good fix from a bad one. Fix it first, or choose checks with --check.`), { tail: failed.tail });
32
+ }
33
+
34
+ log('measuring the baseline …');
35
+ const baseline = await profile('baseline');
36
+ session.baseline = { file: baseline.file, summary: baseline.result.summary, url: baseline.result.url };
37
+ let current = [baseline.file];
38
+ let present = null; // finding keys the latest kept change still measures
39
+
40
+ const { candidates, skipped } = rankFindings(baseline.result.findings, { exists: ws.exists, history, retry, max: maxFixes });
41
+ session.skipped = skipped.map(s => ({ title: s.finding.title, reason: s.reason }));
42
+ if (!candidates.length) {
43
+ log('no findings in your own code to fix.');
44
+ return session;
45
+ }
46
+ log(`${candidates.length} finding${candidates.length === 1 ? '' : 's'} to attempt, most costly first.`);
47
+
48
+ for (const [i, f] of candidates.entries()) {
49
+ if (signal?.aborted) break;
50
+ const n = `${i + 1}/${candidates.length}`;
51
+ if (present && !present.has(f.key)) {
52
+ // one fix often clears several findings (a memoised row also stops its hot file)
53
+ session.skipped.push({ title: f.title, reason: 'already resolved by an earlier kept change' });
54
+ log(`[${n}] – ${f.title}: already resolved by an earlier kept change.`);
55
+ continue;
56
+ }
57
+ const attempt = { finding: { title: f.title, file: f.file, line: f.line, severity: f.severity, impactMs: f.impactMs, key: f.key }, outcome: null, reason: '', changes: [] };
58
+ session.attempts.push(attempt);
59
+ const discard = (outcome, reason) => {
60
+ ws.undo();
61
+ Object.assign(attempt, { outcome, reason });
62
+ log(`[${n}] ✗ ${reason} — change undone.`);
63
+ };
64
+
65
+ log(`[${n}] ${f.title} — ${f.file}${f.line ? `:${f.line}` : ''}`);
66
+ log(`[${n}] asking ${agent.name} for one focused change …`);
67
+ const t0 = Date.now();
68
+ try {
69
+ const res = await agent.run(fixPrompt(f, { url }), { cwd: ws.projectDir, signal, finding: attempt.finding });
70
+ Object.assign(attempt, { summary: res.summary, costUsd: res.costUsd, agentMs: Date.now() - t0 });
71
+ if (res.error) throw new Error(res.error);
72
+ } catch (err) {
73
+ discard('agent-error', `the agent failed: ${err.message}`);
74
+ if (FATAL.test(err.message)) throw Object.assign(new Error(`${agent.name} cannot work: ${err.message}`), { session });
75
+ continue;
76
+ }
77
+
78
+ const changes = ws.changes();
79
+ attempt.changes = changes;
80
+ if (!changes.length) {
81
+ Object.assign(attempt, { outcome: 'no-change', reason: 'the agent made no change' });
82
+ log(`[${n}] – the agent made no change.`);
83
+ continue;
84
+ }
85
+ attempt.diff = ws.diff();
86
+ log(`[${n}] changed ${changes.map(c => `${c.path} (+${c.added} −${c.removed})`).join(', ')}${attempt.summary ? `: ${attempt.summary}` : ''}`);
87
+ const problem = scopeProblem(changes, { maxLines });
88
+ if (problem) {
89
+ discard('out-of-scope', problem);
90
+ continue;
91
+ }
92
+
93
+ if (checks.length) {
94
+ log(`[${n}] running the checks …`);
95
+ const results = await runChecks();
96
+ attempt.checks = results.map(r => ({ name: r.name, ok: r.ok }));
97
+ const failed = results.find(r => !r.ok);
98
+ if (failed) {
99
+ attempt.checkTail = failed.tail;
100
+ discard('checks-failed', `"${failed.name}" failed`);
101
+ continue;
102
+ }
103
+ }
104
+
105
+ log(`[${n}] measuring the change …`);
106
+ const after = [await profile(`after ${i + 1}`)];
107
+ let comparison = await compare(current, after.map(a => a.file));
108
+ let verdict = judge(comparison);
109
+ if (verdict.decision === 'measure-again') {
110
+ log(`[${n}] improved with low confidence; measuring again …`);
111
+ after.push(await profile(`after ${i + 1}, more runs`));
112
+ comparison = await compare(current, after.map(a => a.file));
113
+ verdict = judge(comparison);
114
+ if (verdict.decision === 'measure-again') verdict = { decision: 'discard', outcome: 'unproven', reason: 'not proven: still low confidence after more runs' };
115
+ }
116
+ attempt.comparison = { verdict: comparison.verdict, confidence: comparison.confidence, headlines: comparison.headlines, resolved: comparison.findings.resolved.map(x => x.title), added: comparison.findings.added.map(x => x.title) };
117
+ if (verdict.decision !== 'keep') {
118
+ discard(verdict.outcome, verdict.reason);
119
+ continue;
120
+ }
121
+
122
+ const message = [
123
+ `perf: ${f.title.replace(/\s+/g, ' ').slice(0, 72)}`,
124
+ '',
125
+ ...(attempt.summary ? [attempt.summary, ''] : []),
126
+ `Verified by spectra fix: ${comparison.verdict} (confidence: ${comparison.confidence}).`,
127
+ ...verdict.headlines.map(h => `- ${h}`),
128
+ ...(attempt.checks?.length ? [`Checks passed: ${attempt.checks.map(c => c.name).join(', ')}.`] : []),
129
+ `Finding: ${f.file}${f.line ? `:${f.line}` : ''}`,
130
+ ].join('\n');
131
+ attempt.commit = ws.commit(
132
+ changes.map(c => c.path),
133
+ message,
134
+ );
135
+ Object.assign(attempt, { outcome: 'kept', reason: verdict.reason });
136
+ session.kept++;
137
+ current = after.map(a => a.file);
138
+ present = new Set(after.flatMap(a => a.result.findings || []).map(findingKey));
139
+ log(`[${n}] ✓ kept (${attempt.commit}): ${verdict.reason}`);
140
+ }
141
+
142
+ if (session.kept) {
143
+ const overall = await compare([baseline.file], current);
144
+ session.final = { verdict: overall.verdict, confidence: overall.confidence, headlines: overall.headlines, resolved: overall.findings.resolved.map(x => x.title) };
145
+ }
146
+ return session;
147
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * The rules of `spectra fix`: which findings to attempt, what an agent may
3
+ * change, and when a change has earned its place. Pure functions, so the
4
+ * rules are tested on their own (test-fix.js).
5
+ */
6
+ import { findingKey } from '../../shared/compare.js';
7
+
8
+ const SEVERITY = { critical: 0, warning: 1 };
9
+
10
+ /** A finding's source file, when it points into the project (not a dependency or a URL). */
11
+ export function ownFile(f) {
12
+ const file = f.file || f.culprits?.find(c => c.file)?.file || null;
13
+ if (!file || /^[a-z]+:\/\//i.test(file) || /(^|\/)node_modules\//.test(file) || file.startsWith('/@') || file.startsWith('..')) return null;
14
+ return file.replace(/^\.\//, '');
15
+ }
16
+
17
+ /**
18
+ * The findings worth an attempt, most costly first: critical or warning,
19
+ * pointing at a file in the project, one per place in the code, and not one
20
+ * that failed in an earlier session (unless retry).
21
+ *
22
+ * @param {object[]} findings brief findings from a profile result
23
+ * @param {object} o
24
+ * @param {(file: string) => boolean} o.exists
25
+ * @param {Record<string, {outcome: string}>} [o.history]
26
+ * @returns {{ candidates: object[], skipped: {finding: object, reason: string}[] }}
27
+ */
28
+ export function rankFindings(findings, { exists, history = {}, retry = false, max = 3 }) {
29
+ const candidates = [];
30
+ const skipped = [];
31
+ const taken = new Set();
32
+ const ordered = [...(findings || [])].sort(
33
+ (a, b) => (SEVERITY[a.severity] ?? 9) - (SEVERITY[b.severity] ?? 9) || (b.impactMs || 0) - (a.impactMs || 0),
34
+ );
35
+ for (const f of ordered) {
36
+ if (!(f.severity in SEVERITY)) continue;
37
+ const file = ownFile(f);
38
+ if (!file || !exists(file)) continue;
39
+ const key = findingKey(f);
40
+ const spot = `${file}:${f.line ?? ''}`;
41
+ if (taken.has(spot) || taken.has(key)) continue;
42
+ const past = history[key];
43
+ if (past && past.outcome !== 'kept' && !retry) {
44
+ skipped.push({ finding: f, reason: `an earlier attempt was ${past.outcome.replace(/-/g, ' ')} (use --retry to try again)` });
45
+ continue;
46
+ }
47
+ taken.add(spot);
48
+ taken.add(key);
49
+ candidates.push({ ...f, file, key });
50
+ if (candidates.length >= max) break;
51
+ }
52
+ return { candidates, skipped };
53
+ }
54
+
55
+ // files an agent must not touch: dependencies, tooling, tests and CI
56
+ const OFF_LIMITS = [
57
+ /(^|\/)package\.json$/,
58
+ /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?)$/,
59
+ /(^|\/)[^/]*\.config\.[cm]?[jt]s$/,
60
+ /(^|\/)tsconfig[^/]*\.json$/,
61
+ /(^|\/)\.(eslintrc|prettierrc|babelrc)[^/]*$/,
62
+ /(^|\/)spectra\.config\.json$/,
63
+ /(^|\/)(__tests__|__snapshots__|tests?|e2e|cypress|playwright)\//,
64
+ /\.(test|spec)\.[cm]?[jt]sx?$/,
65
+ /(^|\/)\.github\//,
66
+ /(^|\/)\.spectra\//,
67
+ ];
68
+
69
+ /**
70
+ * What is wrong with a proposed change, if anything.
71
+ * @param {{path: string, status: string, added: number, removed: number}[]} changes
72
+ */
73
+ export function scopeProblem(changes, { maxLines = 150 } = {}) {
74
+ if (!changes.length) return null;
75
+ const deleted = changes.find(c => c.status === 'D');
76
+ if (deleted) return `deleted ${deleted.path}`;
77
+ const forbidden = changes.find(c => OFF_LIMITS.some(re => re.test(c.path)));
78
+ if (forbidden) return `changed ${forbidden.path}, which a fix may not touch (dependencies, configuration, tests and CI are off limits)`;
79
+ const lines = changes.reduce((n, c) => n + c.added + c.removed, 0);
80
+ if (lines > maxLines) return `${lines} changed lines, more than the ${maxLines} allowed for one focused fix`;
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * Keep, discard or measure again, from spectra compare's comparison of the
86
+ * code before the change with the code after it.
87
+ */
88
+ export function judge(c) {
89
+ if (!c) return { decision: 'discard', outcome: 'not-measured', reason: 'nothing to compare' };
90
+ const worse = c.headlines.filter(h => h.kind === 'degraded').map(h => h.text);
91
+ const better = c.headlines.filter(h => h.kind === 'improved').map(h => h.text);
92
+ if (c.verdict === 'regressed') return { decision: 'discard', outcome: 'slower', reason: `slower: ${worse[0] || 'a key metric got worse'}` };
93
+ if (c.verdict === 'mixed') return { decision: 'discard', outcome: 'mixed', reason: `mixed: ${worse[0] || 'something got worse'}` };
94
+ if (c.verdict !== 'improved') return { decision: 'discard', outcome: 'no-gain', reason: 'no measurable improvement: every change was within run-to-run noise' };
95
+ const critical = c.findings.added.find(f => f.severity === 'critical');
96
+ if (critical) return { decision: 'discard', outcome: 'new-problem', reason: `introduced a critical finding: ${critical.title}` };
97
+ if (c.confidence === 'low') return { decision: 'measure-again', outcome: 'unproven', reason: 'improved, but with low confidence' };
98
+ return { decision: 'keep', outcome: 'kept', reason: better[0] || 'improved', headlines: better };
99
+ }
100
+
101
+ /** The instructions an agent gets for one finding. */
102
+ export function fixPrompt(f, { url } = {}) {
103
+ const culprits = (f.culprits || [])
104
+ .slice(0, 5)
105
+ .map(c => ` - ${c.fn || '(anonymous)'}${c.file ? ` in ${c.file}${c.line ? `:${c.line}` : ''}` : ''}${c.ms ? `: ${c.ms} ms` : ''}${c.note ? ` (${c.note})` : ''}`);
106
+ return [
107
+ 'Fix one frontend performance problem that Spectra, a profiler, measured in this project.',
108
+ '',
109
+ `Finding: ${f.title}`,
110
+ `Where: ${f.file}${f.line ? `:${f.line}` : ''}`,
111
+ `Cost: about ${f.impactMs} ms${f.seenIn ? `, seen in ${f.seenIn} runs` : ''}`,
112
+ ...(f.detail ? [`Detail: ${f.detail}`] : []),
113
+ ...(culprits.length ? ['Where the time went:', ...culprits] : []),
114
+ ...(f.fix ? [`Suggested fix: ${f.fix}`] : []),
115
+ '',
116
+ 'Make ONE focused change that removes or reduces this cost.',
117
+ 'Rules:',
118
+ '- Keep the behaviour exactly the same: the same output, UI and data. Never delete features, skip work the page needs, or hide content to look faster.',
119
+ '- Edit only the source files involved. Do not edit package.json, lockfiles, configuration, tests or CI files, and do not delete files.',
120
+ '- Keep the change small: a few lines to a few dozen.',
121
+ "- Do not run commands. Spectra runs the project's checks and measures the change after you finish.",
122
+ ...(url ? [`- The app is served at ${url} while you work, if you want to read it.`] : []),
123
+ '',
124
+ 'When you are done, reply with one sentence that describes the change.',
125
+ ].join('\n');
126
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The session report: a Markdown summary for people (and pull requests) and
3
+ * the JSON behind it.
4
+ */
5
+ const OUTCOME = {
6
+ kept: '✅ kept',
7
+ 'no-change': '➖ no change',
8
+ 'out-of-scope': '⛔ out of scope',
9
+ 'checks-failed': '❌ checks failed',
10
+ 'no-gain': '➖ no measurable gain',
11
+ slower: '❌ slower',
12
+ mixed: '⚠️ mixed',
13
+ 'new-problem': '❌ new problem',
14
+ unproven: '➖ not proven',
15
+ 'agent-error': '⚠️ agent error',
16
+ };
17
+
18
+ export function reportMarkdown(s) {
19
+ const lines = [
20
+ `## ⚡ Spectra fix: ${s.kept} of ${s.attempts.length} finding${s.attempts.length === 1 ? '' : 's'} fixed`,
21
+ '',
22
+ `Agent: ${s.agent} · checks: ${s.checks.length ? s.checks.join(', ') : 'none'} · ${s.runs} page-load runs per measurement${s.branch && s.kept ? ` · branch \`${s.branch}\`` : ''}`,
23
+ '',
24
+ ];
25
+ if (s.final) {
26
+ lines.push(`**Overall, before → after all kept fixes:** ${s.final.verdict} (confidence: ${s.final.confidence})`, '', ...s.final.headlines.map(h => `- ${h.kind === 'improved' ? '✅' : '❌'} ${h.text}`), '');
27
+ }
28
+ if (s.attempts.length) {
29
+ lines.push('| Finding | Where | Outcome | Why |', '| --- | --- | --- | --- |');
30
+ for (const a of s.attempts)
31
+ lines.push(`| ${a.finding.title.replace(/\|/g, '\\|')} | \`${a.finding.file}${a.finding.line ? `:${a.finding.line}` : ''}\` | ${OUTCOME[a.outcome] || a.outcome}${a.commit ? ` (${a.commit})` : ''} | ${(a.reason || '').replace(/\|/g, '\\|')} |`);
32
+ lines.push('');
33
+ }
34
+ for (const a of s.attempts.filter(x => x.summary)) lines.push(`- **${a.finding.title}**: ${a.summary}`);
35
+ if (s.skipped.length) lines.push('', 'Skipped:', ...s.skipped.map(x => `- ${x.title}: ${x.reason}`));
36
+ lines.push('', '<sub>Every kept change passed the checks and measured faster beyond run-to-run noise. Review it like any other change.</sub>');
37
+ return `${lines.join('\n')}\n`;
38
+ }
@@ -0,0 +1,227 @@
1
+ // The AI fix harness without a browser or a model: its rules, its loop with
2
+ // fake measurements and agents, and its git copy in a throwaway repository.
3
+ import assert from 'node:assert/strict';
4
+ import { execFileSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { test } from 'node:test';
9
+ import { commandAgent } from './agent.js';
10
+ import { detectChecks } from './checks.js';
11
+ import { runFix } from './harness.js';
12
+ import { fixPrompt, judge, rankFindings, scopeProblem } from './policy.js';
13
+ import { reportMarkdown } from './report.js';
14
+ import { changesIn, commitChange, commitsOn, createWorkspace, ignoreSpectra, removeWorkspace, repoOf, uncommitted, undoChanges } from './workspace.js';
15
+
16
+ const finding = (id, extra = {}) => ({ id, severity: 'critical', title: `${id} is slow`, impactMs: 500, file: 'src/Row.jsx', line: 3, fix: 'memoize it', culprits: [{ fn: 'Row', file: 'src/Row.jsx', line: 3, ms: 500 }], ...extra });
17
+ const comparison = (verdict, confidence = 'medium', extra = {}) => ({
18
+ verdict,
19
+ confidence,
20
+ headlines: verdict === 'improved' ? [{ kind: 'improved', text: 'JavaScript CPU time: 1.55 s → 107 ms (−93%)' }] : verdict === 'neutral' ? [] : [{ kind: 'degraded', text: 'Total blocking time: 0 ms → 300 ms' }],
21
+ findings: { added: [], resolved: [], persisting: [], ...extra },
22
+ });
23
+
24
+ test('ranks own-code findings, most costly first, one per place', () => {
25
+ const findings = [
26
+ finding('row-render', { impactMs: 1441 }),
27
+ finding('row-count', { impactMs: 1440, title: 'Row rendered 7200×' }), // same file and line
28
+ finding('heavy', { file: 'src/heavy.js', line: 2, impactMs: 1425, culprits: [{ fn: 'burn', file: 'src/heavy.js', line: 2 }] }),
29
+ finding('bundle', { file: 'node_modules/react-dom/index.js', impactMs: 600 }),
30
+ finding('note', { severity: 'info', file: 'src/App.jsx' }),
31
+ finding('gone', { file: 'src/Deleted.jsx' }),
32
+ ];
33
+ const exists = f => !f.includes('Deleted');
34
+ const { candidates } = rankFindings(findings, { exists, max: 5 });
35
+ assert.deepEqual(candidates.map(c => c.id), ['row-render', 'heavy']);
36
+ const again = rankFindings(findings, { exists, history: { 'row-render': { outcome: 'checks-failed' } } });
37
+ assert.deepEqual(again.candidates.map(c => c.id), ['row-count', 'heavy'], 'a failed finding is skipped, so the next one at that line gets its chance');
38
+ assert.match(again.skipped[0].reason, /checks failed/);
39
+ assert.deepEqual(rankFindings(findings, { exists, history: { 'row-render': { outcome: 'checks-failed' } }, retry: true }).candidates[0].id, 'row-render');
40
+ });
41
+
42
+ test('keeps changes inside the source, small, and without deleting files', () => {
43
+ const src = (p, added = 3, removed = 1, status = 'M') => ({ path: p, status, added, removed });
44
+ assert.equal(scopeProblem([src('src/Row.jsx')]), null);
45
+ assert.match(scopeProblem([src('package.json')]), /may not touch/);
46
+ assert.match(scopeProblem([src('pnpm-lock.yaml')]), /may not touch/);
47
+ assert.match(scopeProblem([src('src/Row.test.jsx')]), /may not touch/);
48
+ assert.match(scopeProblem([src('vite.config.js')]), /may not touch/);
49
+ assert.match(scopeProblem([src('src/Row.jsx', 0, 9, 'D')]), /deleted/);
50
+ assert.match(scopeProblem([src('src/Row.jsx', 200, 10)]), /more than the 150/);
51
+ });
52
+
53
+ test('judges a change from the comparison', () => {
54
+ assert.equal(judge(comparison('improved')).decision, 'keep');
55
+ assert.equal(judge(comparison('improved', 'high')).decision, 'keep');
56
+ assert.equal(judge(comparison('improved', 'low')).decision, 'measure-again');
57
+ assert.equal(judge(comparison('neutral')).outcome, 'no-gain');
58
+ assert.equal(judge(comparison('regressed')).outcome, 'slower');
59
+ assert.equal(judge(comparison('mixed')).outcome, 'mixed');
60
+ assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'Long task' }] })).outcome, 'new-problem');
61
+ });
62
+
63
+ test('tells the agent the finding and the rules', () => {
64
+ const p = fixPrompt(finding('row'), { url: 'http://localhost:5199' });
65
+ assert.match(p, /row is slow/);
66
+ assert.match(p, /src\/Row\.jsx:3/);
67
+ assert.match(p, /Keep the behaviour exactly the same/);
68
+ assert.match(p, /Do not run commands/);
69
+ assert.match(p, /localhost:5199/);
70
+ });
71
+
72
+ // --- the loop, with fakes
73
+ const bothFindings = () => [finding('row-render'), finding('heavy', { file: 'src/heavy.js', line: 2, culprits: [] })];
74
+ function fakes({ agentChanges, checks = [true, true, true, true], verdicts, measured = bothFindings }) {
75
+ const calls = { commits: [], undos: 0, profiles: 0, compares: [] };
76
+ let pending = [];
77
+ let checkRun = 0;
78
+ let verdictRun = 0;
79
+ const workspace = {
80
+ projectDir: '/tmp/project',
81
+ exists: () => true,
82
+ changes: () => pending,
83
+ diff: () => 'diff',
84
+ commit: (files, message) => (calls.commits.push({ files, message }), (pending = []), 'abc1234'),
85
+ undo: () => ((pending = []), calls.undos++),
86
+ };
87
+ let agentRun = 0;
88
+ return {
89
+ calls,
90
+ options: {
91
+ agent: { name: 'fake', run: async () => ((pending = agentChanges[agentRun++] || []), { summary: 'memoized the work' }) },
92
+ workspace,
93
+ checks: [{ name: 'npm test' }],
94
+ runChecks: async () => [{ name: 'npm test', ok: checks[checkRun++] ?? true, tail: 'expected 120 rows' }],
95
+ profile: async label => ((calls.profiles++), { file: `${label}.json`, result: { url: 'http://localhost:5199', summary: {}, findings: measured(label) } }),
96
+ compare: async (before, after) => (calls.compares.push([before, after]), verdicts[verdictRun++] || comparison('neutral')),
97
+ },
98
+ };
99
+ }
100
+ const edit = [{ path: 'src/Row.jsx', status: 'M', added: 2, removed: 1 }];
101
+
102
+ test('keeps a change that passes the checks and measures faster, and builds on it', async () => {
103
+ const { calls, options } = fakes({ agentChanges: [edit, edit], verdicts: [comparison('improved'), comparison('neutral'), comparison('improved')] });
104
+ const s = await runFix(options);
105
+ assert.deepEqual(s.attempts.map(a => a.outcome), ['kept', 'no-gain']);
106
+ assert.equal(s.kept, 1);
107
+ assert.equal(calls.commits.length, 1);
108
+ assert.match(calls.commits[0].message, /^perf: row-render is slow/);
109
+ assert.match(calls.commits[0].message, /Verified by spectra fix: improved \(confidence: medium\)/);
110
+ assert.deepEqual(calls.compares[1], [['after 1.json'], ['after 2.json']], 'the second fix is measured against the first');
111
+ assert.deepEqual(calls.compares[2], [['baseline.json'], ['after 1.json']], 'the overall comparison starts from the baseline');
112
+ assert.equal(s.final.verdict, 'improved');
113
+ assert.match(reportMarkdown({ ...s, runs: 3, branch: 'spectra/fix-x' }), /1 of 2 findings fixed/);
114
+ });
115
+
116
+ test('skips a finding that an earlier kept change already resolved', async () => {
117
+ // the memoised row also stops the hot file: after the first fix, neither is measured
118
+ const measured = label => (label === 'baseline' ? bothFindings() : []);
119
+ const { calls, options } = fakes({ agentChanges: [edit, edit], verdicts: [comparison('improved'), comparison('improved')], measured });
120
+ let asked = 0;
121
+ const run = options.agent.run;
122
+ options.agent.run = (...a) => (asked++, run(...a));
123
+ const s = await runFix(options);
124
+ assert.equal(asked, 1, 'the agent is not asked about the resolved finding');
125
+ assert.deepEqual(s.attempts.map(a => a.outcome), ['kept']);
126
+ assert.deepEqual(s.skipped, [{ title: 'heavy is slow', reason: 'already resolved by an earlier kept change' }]);
127
+ assert.equal(calls.profiles, 2, 'baseline and one measurement');
128
+ assert.match(reportMarkdown({ ...s, runs: 3, branch: 'spectra/fix-x' }), /heavy is slow: already resolved by an earlier kept change/);
129
+ });
130
+
131
+ test('undoes a change whose checks fail, without measuring it', async () => {
132
+ const { calls, options } = fakes({ agentChanges: [edit], checks: [true, false], verdicts: [] });
133
+ options.workspace.exists = f => f === 'src/Row.jsx';
134
+ const s = await runFix(options);
135
+ assert.equal(s.attempts[0].outcome, 'checks-failed');
136
+ assert.equal(calls.undos, 1);
137
+ assert.equal(calls.profiles, 1, 'only the baseline was measured');
138
+ assert.equal(calls.commits.length, 0);
139
+ });
140
+
141
+ test('refuses to start when a check fails before any change', async () => {
142
+ const { options } = fakes({ agentChanges: [], checks: [false], verdicts: [] });
143
+ await assert.rejects(runFix(options), /fails before any change/);
144
+ });
145
+
146
+ test('undoes out-of-scope and empty changes', async () => {
147
+ const { calls, options } = fakes({ agentChanges: [[{ path: 'package.json', status: 'M', added: 1, removed: 1 }], []], verdicts: [] });
148
+ const s = await runFix(options);
149
+ assert.deepEqual(s.attempts.map(a => a.outcome), ['out-of-scope', 'no-change']);
150
+ assert.equal(calls.undos, 1);
151
+ });
152
+
153
+ test('stops at once when the agent cannot work at all', async () => {
154
+ const { options } = fakes({ agentChanges: [], verdicts: [] });
155
+ let runs = 0;
156
+ options.agent = { name: 'Claude Code', run: async () => (runs++, { summary: '', error: 'Not logged in · Please run /login' }) };
157
+ await assert.rejects(runFix(options), /Claude Code cannot work: Not logged in/);
158
+ assert.equal(runs, 1, 'no other finding was tried');
159
+ });
160
+
161
+ test('measures again when the gain has low confidence', async () => {
162
+ const kept = fakes({ agentChanges: [edit], verdicts: [comparison('improved', 'low'), comparison('improved', 'medium'), comparison('improved')] });
163
+ kept.options.workspace.exists = f => f === 'src/Row.jsx';
164
+ const s = await runFix(kept.options);
165
+ assert.equal(s.attempts[0].outcome, 'kept');
166
+ assert.deepEqual(kept.calls.compares[1], [['baseline.json'], ['after 1.json', 'after 1, more runs.json']]);
167
+ const unproven = fakes({ agentChanges: [edit], verdicts: [comparison('improved', 'low'), comparison('improved', 'low')] });
168
+ unproven.options.workspace.exists = f => f === 'src/Row.jsx';
169
+ assert.equal((await runFix(unproven.options)).attempts[0].outcome, 'unproven');
170
+ });
171
+
172
+ // --- the git copy, in a throwaway repository
173
+ const sh = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
174
+ function repo() {
175
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-fix-test-')));
176
+ sh(dir, 'init', '-q', '-b', 'main');
177
+ fs.mkdirSync(path.join(dir, 'app/src'), { recursive: true });
178
+ fs.mkdirSync(path.join(dir, 'app/node_modules/dep'), { recursive: true });
179
+ fs.writeFileSync(path.join(dir, 'app/src/Row.jsx'), 'export const Row = () => null;\n');
180
+ fs.writeFileSync(path.join(dir, 'app/package.json'), JSON.stringify({ scripts: { test: 'echo "Error: no test specified" && exit 1', build: 'vite build', typecheck: 'tsc' } }));
181
+ fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules\n');
182
+ sh(dir, 'add', '-A');
183
+ sh(dir, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init');
184
+ return dir;
185
+ }
186
+
187
+ test('works in a git copy: links dependencies, commits kept changes, undoes the rest', () => {
188
+ const root = repo();
189
+ const { root: r, sub } = repoOf(path.join(root, 'app'));
190
+ assert.equal(r, root);
191
+ assert.equal(sub, 'app');
192
+ ignoreSpectra(root);
193
+ fs.mkdirSync(path.join(root, 'app/.spectra/fix'), { recursive: true });
194
+ assert.deepEqual(uncommitted(root), [], '.spectra is ignored');
195
+
196
+ const ws = createWorkspace({ root, sub, dir: path.join(root, 'app/.spectra/fix/s1/worktree'), branch: 'spectra/fix-test' });
197
+ assert.ok(fs.lstatSync(path.join(ws.projectDir, 'node_modules')).isSymbolicLink());
198
+
199
+ fs.writeFileSync(path.join(ws.projectDir, 'src/Row.jsx'), 'export const Row = () => null; // faster\n');
200
+ fs.writeFileSync(path.join(ws.projectDir, 'src/cache.js'), 'export const cache = new Map();\n');
201
+ const changes = changesIn(ws);
202
+ assert.deepEqual(changes.map(c => [c.path, c.status]).sort(), [['src/Row.jsx', 'M'], ['src/cache.js', 'A']]);
203
+ commitChange(ws, changes.map(c => c.path), 'perf: test');
204
+ assert.equal(commitsOn(root, 'spectra/fix-test'), 1);
205
+
206
+ fs.writeFileSync(path.join(ws.projectDir, 'src/Row.jsx'), 'broken');
207
+ fs.writeFileSync(path.join(ws.projectDir, 'src/junk.js'), 'junk');
208
+ undoChanges(ws);
209
+ assert.deepEqual(changesIn(ws), []);
210
+ assert.ok(fs.existsSync(path.join(ws.projectDir, 'node_modules')), 'the dependency link survives an undo');
211
+ assert.equal(fs.readFileSync(path.join(root, 'app/src/Row.jsx'), 'utf8'), 'export const Row = () => null;\n', 'the user\'s files never change');
212
+
213
+ removeWorkspace(root, ws, { keepBranch: true });
214
+ assert.ok(!fs.existsSync(ws.dir));
215
+ assert.equal(commitsOn(root, 'spectra/fix-test'), 1, 'the branch with the fix stays');
216
+ fs.rmSync(root, { recursive: true, force: true });
217
+ });
218
+
219
+ test('finds checks in package.json and runs any agent command', async () => {
220
+ const root = repo();
221
+ assert.deepEqual(detectChecks(path.join(root, 'app')).map(c => c.cmd), ['npm run typecheck', 'npm run build'], 'the npm placeholder test is not a check');
222
+ const agent = commandAgent(`node -e "const fs=require('fs');const p=fs.readFileSync(process.env.SPECTRA_FIX_PROMPT_FILE,'utf8');fs.writeFileSync('src/Row.jsx','// '+p.split('\\n')[2]+'\\n');console.log('rewrote Row')"`);
223
+ const res = await agent.run('Fix it.\n\nFinding: Row is slow', { cwd: path.join(root, 'app'), finding: {} });
224
+ assert.equal(res.summary, 'rewrote Row');
225
+ assert.equal(fs.readFileSync(path.join(root, 'app/src/Row.jsx'), 'utf8'), '// Finding: Row is slow\n');
226
+ fs.rmSync(root, { recursive: true, force: true });
227
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The isolated copy `spectra fix` works in: a git worktree of the last commit
3
+ * on its own branch, inside .spectra/fix/<session>/ (which git is told to
4
+ * ignore). Every attempt is either committed on that branch or undone, so a
5
+ * failed attempt never reaches the user's working tree.
6
+ */
7
+ import { execFileSync, spawnSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ const gitRaw = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
12
+ const git = (cwd, args) => gitRaw(cwd, args).trim();
13
+ const tryGit = (cwd, args) => {
14
+ try {
15
+ return git(cwd, args);
16
+ } catch {
17
+ return null;
18
+ }
19
+ };
20
+
21
+ /** The repository around `dir`, and where the project sits inside it. */
22
+ export function repoOf(dir) {
23
+ const root = tryGit(dir, ['rev-parse', '--show-toplevel']);
24
+ if (!root) throw new Error('spectra fix needs a git repository: it works on a copy of your last commit.');
25
+ const real = fs.realpathSync(dir);
26
+ return { root: fs.realpathSync(root), sub: path.relative(fs.realpathSync(root), real) };
27
+ }
28
+
29
+ /** Uncommitted changes in the project (other than Spectra's own files). */
30
+ export function uncommitted(root) {
31
+ return gitRaw(root, ['status', '--porcelain', '--untracked-files=normal'])
32
+ .split('\n')
33
+ .filter(Boolean)
34
+ .filter(l => !/(^|\/)\.spectra(\/|$)/.test(l.slice(3)));
35
+ }
36
+
37
+ /** Tell git, locally, to ignore .spectra/ so the session never shows up as a change. */
38
+ export function ignoreSpectra(root) {
39
+ const file = path.resolve(root, git(root, ['rev-parse', '--git-path', 'info/exclude']));
40
+ const now = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
41
+ if (!now.split('\n').some(l => l.trim() === '.spectra/' || l.trim() === '.spectra')) {
42
+ fs.mkdirSync(path.dirname(file), { recursive: true });
43
+ fs.appendFileSync(file, `${now && !now.endsWith('\n') ? '\n' : ''}.spectra/\n`);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * A worktree of HEAD on a new branch, with the project's dependencies linked
49
+ * in (or installed fresh, with install).
50
+ */
51
+ export function createWorkspace({ root, sub, dir, branch, install = false, log = () => {} }) {
52
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
53
+ git(root, ['worktree', 'add', '-q', '-b', branch, dir, 'HEAD']);
54
+ const projectDir = path.join(dir, sub);
55
+ if (install) {
56
+ const lock = f => fs.existsSync(path.join(projectDir, f));
57
+ const [cmd, ...args] = lock('pnpm-lock.yaml') ? ['pnpm', 'install', '--frozen-lockfile'] : lock('yarn.lock') ? ['yarn', 'install', '--frozen-lockfile'] : lock('package-lock.json') ? ['npm', 'ci'] : ['npm', 'install'];
58
+ log(`installing dependencies in the copy (${cmd} ${args.join(' ')}) …`);
59
+ const r = spawnSync(cmd, args, { cwd: projectDir, stdio: 'ignore', shell: process.platform === 'win32' });
60
+ if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed in the copy.`);
61
+ } else {
62
+ // node_modules of the project and of the repository root (hoisted workspaces)
63
+ for (const rel of new Set([sub, ''])) {
64
+ const from = path.join(root, rel, 'node_modules');
65
+ const to = path.join(dir, rel, 'node_modules');
66
+ if (fs.existsSync(from) && !fs.existsSync(to)) fs.symlinkSync(from, to, process.platform === 'win32' ? 'junction' : 'dir');
67
+ }
68
+ }
69
+ return { dir, projectDir, branch };
70
+ }
71
+
72
+ /** What the agent changed: [{ path, status, added, removed }], paths relative to the project. */
73
+ export function changesIn(ws) {
74
+ const status = gitRaw(ws.projectDir, ['status', '--porcelain', '--untracked-files=all', '--', '.'])
75
+ .split('\n')
76
+ .filter(Boolean)
77
+ .map(l => ({ status: l.slice(0, 2).trim()[0] === '?' ? 'A' : l.slice(0, 2).trim()[0], file: l.slice(3).replace(/^"|"$/g, '') }))
78
+ .filter(c => !/(^|\/)node_modules$/.test(c.file));
79
+ const counts = new Map(
80
+ git(ws.projectDir, ['diff', '--numstat', 'HEAD', '--', '.'])
81
+ .split('\n')
82
+ .filter(Boolean)
83
+ .map(l => {
84
+ const [a, r, f] = l.split('\t');
85
+ return [f, { added: Number(a) || 0, removed: Number(r) || 0 }];
86
+ }),
87
+ );
88
+ const prefix = git(ws.projectDir, ['rev-parse', '--show-prefix']);
89
+ return status.map(c => {
90
+ const rel = c.file.startsWith(prefix) ? c.file.slice(prefix.length) : c.file;
91
+ const n = counts.get(c.file) || (c.status === 'A' ? { added: lineCount(path.join(ws.dir, c.file)), removed: 0 } : { added: 0, removed: 0 });
92
+ return { path: rel, status: c.status, ...n };
93
+ });
94
+ }
95
+ const lineCount = file => {
96
+ try {
97
+ return fs.readFileSync(file, 'utf8').split('\n').length;
98
+ } catch {
99
+ return 0;
100
+ }
101
+ };
102
+
103
+ /** The diff of the files a change touched, for the report. */
104
+ export const diffOf = ws => git(ws.projectDir, ['diff', 'HEAD', '--', '.']);
105
+
106
+ /** Commit exactly these files on the session branch. */
107
+ export function commitChange(ws, files, message) {
108
+ git(ws.projectDir, ['add', '--', ...files]);
109
+ const named = tryGit(ws.dir, ['config', 'user.email']);
110
+ const who = named ? [] : ['-c', 'user.name=Spectra fix', '-c', 'user.email=spectra-fix@localhost'];
111
+ git(ws.dir, [...who, 'commit', '-q', '-m', message]);
112
+ undoChanges(ws); // anything the checks left behind
113
+ return git(ws.dir, ['rev-parse', '--short', 'HEAD']);
114
+ }
115
+
116
+ /** Undo everything since the last commit (the dependency link stays). */
117
+ export function undoChanges(ws) {
118
+ git(ws.dir, ['reset', '-q', '--hard']);
119
+ git(ws.dir, ['clean', '-fdq', '-e', 'node_modules']);
120
+ }
121
+
122
+ /** Remove the copy; keep the branch only if it holds fixes. */
123
+ export function removeWorkspace(root, ws, { keepBranch }) {
124
+ tryGit(root, ['worktree', 'remove', '--force', ws.dir]);
125
+ if (fs.existsSync(ws.dir)) fs.rmSync(ws.dir, { recursive: true, force: true });
126
+ tryGit(root, ['worktree', 'prune']);
127
+ if (!keepBranch) tryGit(root, ['branch', '-D', ws.branch]);
128
+ }
129
+
130
+ export const commitsOn = (root, branch) => Number(tryGit(root, ['rev-list', '--count', `HEAD..${branch}`]) || 0);