@progen-labs/spectra 0.6.0 → 0.7.0
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/CHANGELOG.md +94 -0
- package/README.md +156 -295
- package/agents/spectra.md +55 -37
- package/bin/spectra.js +63 -23
- package/dist/assets/index-BDIjT029.js +11 -0
- package/dist/index.html +1 -1
- package/package.json +29 -6
- package/schema/config.schema.json +1 -1
- package/schema/result.schema.json +64 -3
- package/server/agents-cli.js +6 -2
- package/server/agents.js +69 -17
- package/server/explore/boot.js +2 -0
- package/server/fix/agent.js +90 -7
- package/server/fix/cli.js +180 -208
- package/server/fix/policy.js +3 -1
- package/server/fix/test-fix.js +43 -2
- package/server/improve/cli.js +304 -0
- package/server/improve/session.js +188 -0
- package/server/improve/test-improve.js +135 -0
- package/server/mcp.js +64 -2
- package/server/signin.js +25 -2
- package/server/test-agents.js +39 -15
- package/server/test-compare.js +10 -0
- package/server/test-mcp.js +8 -3
- package/server/test-signin.js +1 -0
- package/shared/compare.js +18 -3
- package/dist/assets/index-mtYxDBW7.js +0 -11
- package/server/postinstall.js +0 -20
package/server/fix/cli.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* project's checks pass and Spectra
|
|
6
|
-
* noise. Kept changes are commits on a
|
|
2
|
+
* The unattended mode of `spectra improve` (and `spectra fix`, its old name).
|
|
3
|
+
* It runs in the user's project: profiles a copy of the last commit, hands
|
|
4
|
+
* the costliest findings in the project's own code to a coding agent one at
|
|
5
|
+
* a time, and keeps a change only when the project's checks pass and Spectra
|
|
6
|
+
* measures it faster beyond run-to-run noise. Kept changes are commits on a
|
|
7
|
+
* new branch; nothing else is touched.
|
|
7
8
|
*/
|
|
8
9
|
import { spawn, spawnSync } from 'node:child_process';
|
|
9
10
|
import fs from 'node:fs';
|
|
@@ -11,227 +12,198 @@ import path from 'node:path';
|
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
12
13
|
import { intOption, parseArgs } from '../args.js';
|
|
13
14
|
import { bootDevServer } from '../explore/boot.js';
|
|
14
|
-
import { SPECTRA_VERSION } from '../output.js';
|
|
15
|
-
import { claudeAgent,
|
|
15
|
+
import { EXIT, SPECTRA_VERSION } from '../output.js';
|
|
16
|
+
import { agentFromOption, claudeAgent, detectAgent } from './agent.js';
|
|
16
17
|
import { detectChecks, runChecks } from './checks.js';
|
|
17
18
|
import { runFix } from './harness.js';
|
|
18
19
|
import { reportMarkdown } from './report.js';
|
|
19
20
|
import { changesIn, commitChange, commitsOn, createWorkspace, diffOf, ignoreSpectra, removeWorkspace, repoOf, uncommitted, undoChanges } from './workspace.js';
|
|
20
21
|
|
|
21
22
|
const BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin/spectra.js');
|
|
22
|
-
const
|
|
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
|
-
}
|
|
23
|
+
const VALUES = ['runs', 'max-fixes', 'agent', 'model', 'max-budget-usd', 'check', 'command', 'boot-timeout', 'branch', 'out', 'max-lines', 'url'];
|
|
58
24
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
let repo;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if (
|
|
91
|
-
|
|
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, () => {
|
|
25
|
+
/**
|
|
26
|
+
* Runs a session. `agent` is the agent to use when the options name none;
|
|
27
|
+
* without it, the agent found on the machine, or Claude Code.
|
|
28
|
+
* @returns {Promise<number>} the exit code
|
|
29
|
+
*/
|
|
30
|
+
export async function runFixCli(argv, { agent: given = null } = {}) {
|
|
31
|
+
const { opt, flag } = parseArgs(argv, { values: VALUES });
|
|
32
|
+
const log = line => console.error(`spectra: ${line}`);
|
|
33
|
+
const json = flag('json');
|
|
34
|
+
const fail = message => {
|
|
35
|
+
throw Object.assign(new Error(message), { early: true });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const checksFrom = () => {
|
|
39
|
+
const list = [];
|
|
40
|
+
for (let i = 0; i < argv.length; i++) if (argv[i] === '--check' && argv[i + 1]) list.push({ name: argv[i + 1], cmd: argv[++i] });
|
|
41
|
+
return list;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
let ws = null;
|
|
46
|
+
let server = null;
|
|
47
|
+
let child = null;
|
|
48
|
+
let session = null;
|
|
49
|
+
let repo = null;
|
|
50
|
+
let branch = null;
|
|
51
|
+
let sessionDir = null;
|
|
52
|
+
const projectDir = process.cwd();
|
|
53
|
+
|
|
54
|
+
async function cleanup() {
|
|
55
|
+
await server?.stop().catch(() => {});
|
|
56
|
+
if (ws && !flag('keep-worktree')) removeWorkspace(repo.root, ws, { keepBranch: commitsOn(repo.root, ws.branch) > 0 });
|
|
57
|
+
}
|
|
58
|
+
const onSignal = signal => {
|
|
123
59
|
controller.abort();
|
|
124
60
|
child?.kill('SIGTERM');
|
|
125
61
|
log('stopping …');
|
|
126
62
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
63
|
+
};
|
|
64
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, onSignal);
|
|
65
|
+
|
|
66
|
+
/** `spectra <args> --json` in the copy; resolves with the parsed result. */
|
|
67
|
+
function spectra(args) {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
child = spawn(process.execPath, [BIN, ...args, '--json'], {
|
|
70
|
+
cwd: ws.projectDir,
|
|
71
|
+
env: { ...process.env, PERF_FRONTEND_DIR: ws.projectDir, PERF_REPORTS_DIR: path.join(sessionDir, 'reports') },
|
|
72
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
73
|
+
});
|
|
74
|
+
let out = '';
|
|
75
|
+
let err = '';
|
|
76
|
+
child.stdout.on('data', d => (out += d));
|
|
77
|
+
child.stderr.on('data', d => {
|
|
78
|
+
err += d;
|
|
79
|
+
for (const line of String(d).split('\n')) if (/^spectra: run \d+\/\d+/.test(line)) console.error(` ${line.replace(/^spectra:\s*/, '')}`);
|
|
80
|
+
});
|
|
81
|
+
child.on('error', reject);
|
|
82
|
+
child.on('close', code => {
|
|
83
|
+
if (code === 1) return reject(new Error(err.trim().split('\n').filter(l => l.startsWith('spectra:')).pop()?.replace(/^spectra:\s*/, '') || `spectra ${args[0]} failed`));
|
|
84
|
+
try {
|
|
85
|
+
resolve(JSON.parse(out));
|
|
86
|
+
} catch {
|
|
87
|
+
reject(new Error(`spectra ${args[0]} printed no result`));
|
|
88
|
+
}
|
|
89
|
+
});
|
|
144
90
|
});
|
|
145
|
-
|
|
146
|
-
|
|
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}`);
|
|
91
|
+
}
|
|
92
|
+
const slug = s => s.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
|
|
173
93
|
|
|
174
|
-
let
|
|
94
|
+
let agent = given;
|
|
175
95
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
96
|
+
const runs = intOption(opt('runs'), 'runs', { min: 1, max: 10, fallback: 3 });
|
|
97
|
+
const maxFixes = intOption(opt('max-fixes'), 'max-fixes', { min: 1, max: 20, fallback: 3 });
|
|
98
|
+
const maxLines = intOption(opt('max-lines'), 'max-lines', { min: 1, max: 5000, fallback: 150 });
|
|
99
|
+
if (opt('url')) fail('spectra improve 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, or `spectra improve next` to work on a running page.');
|
|
100
|
+
|
|
101
|
+
repo = repoOf(projectDir);
|
|
102
|
+
const dirty = uncommitted(repo.root);
|
|
103
|
+
if (dirty.length)
|
|
104
|
+
fail(`commit or stash your changes first: spectra improve 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 …' : ''}`);
|
|
105
|
+
|
|
106
|
+
let checks = checksFrom();
|
|
107
|
+
if (!checks.length && !flag('no-checks')) checks = detectChecks(projectDir);
|
|
108
|
+
if (!checks.length && !flag('no-checks'))
|
|
109
|
+
fail('no checks to run. A faster page must not be a broken one, so spectra improve 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.');
|
|
110
|
+
if (flag('no-checks')) checks = [];
|
|
111
|
+
|
|
112
|
+
if (!agent) {
|
|
113
|
+
const agentOpts = { model: opt('model') === true ? undefined : opt('model'), budgetUsd: Number(opt('max-budget-usd') ?? 2) };
|
|
114
|
+
const found = opt('agent') ? null : detectAgent();
|
|
115
|
+
agent = opt('agent') ? agentFromOption(String(opt('agent')), agentOpts) : found ? agentFromOption(found.id, { ...agentOpts, bin: found.bin }) : claudeAgent(agentOpts);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
|
119
|
+
const sessionRoot = path.join(projectDir, '.spectra', 'improve');
|
|
120
|
+
sessionDir = path.join(sessionRoot, stamp);
|
|
121
|
+
const historyFile = path.join(sessionRoot, 'history.json');
|
|
122
|
+
branch = typeof opt('branch') === 'string' ? opt('branch') : `spectra/improve-${stamp}`;
|
|
123
|
+
fs.mkdirSync(path.join(sessionDir, 'results'), { recursive: true });
|
|
124
|
+
ignoreSpectra(repo.root);
|
|
125
|
+
|
|
126
|
+
if (branchExists(repo.root, branch)) fail(`branch ${branch} already exists; pass --branch <name>.`);
|
|
127
|
+
log(`working in a copy of ${shortHead(repo.root)} on branch ${branch}, with ${agent.name} …`);
|
|
128
|
+
ws = createWorkspace({ root: repo.root, sub: repo.sub, dir: path.join(sessionDir, 'worktree'), branch, install: flag('install'), log });
|
|
129
|
+
ws.exists = file => fs.existsSync(path.join(ws.projectDir, file));
|
|
130
|
+
ws.changes = () => changesIn(ws);
|
|
131
|
+
ws.diff = () => diffOf(ws);
|
|
132
|
+
ws.commit = (files, message) => commitChange(ws, files, message);
|
|
133
|
+
ws.undo = () => undoChanges(ws);
|
|
134
|
+
|
|
135
|
+
const command = typeof opt('command') === 'string' ? opt('command') : undefined;
|
|
136
|
+
log(`starting the dev server in the copy${command ? ` (${command})` : ''} …`);
|
|
137
|
+
server = await bootDevServer({ root: ws.projectDir, command, timeoutMs: (Number(opt('boot-timeout')) || 60) * 1000 });
|
|
138
|
+
log(`dev server ready at ${server.url}`);
|
|
139
|
+
|
|
140
|
+
let history = {};
|
|
141
|
+
try {
|
|
142
|
+
history = JSON.parse(fs.readFileSync(historyFile, 'utf8'));
|
|
143
|
+
} catch {
|
|
144
|
+
/* first session */
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
session = await runFix({
|
|
148
|
+
agent,
|
|
149
|
+
workspace: ws,
|
|
150
|
+
checks,
|
|
151
|
+
history,
|
|
152
|
+
retry: flag('retry'),
|
|
153
|
+
maxFixes,
|
|
154
|
+
maxLines,
|
|
155
|
+
url: server.url,
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
log,
|
|
158
|
+
runChecks: () => runChecks(checks, { cwd: ws.projectDir, env: { SPECTRA_FIX_URL: server.url }, signal: controller.signal }),
|
|
159
|
+
profile: async label => {
|
|
160
|
+
const file = path.join(sessionDir, 'results', `${slug(label)}.json`);
|
|
161
|
+
const result = await spectra(['performance', '--quick', '--runs', String(runs), '--url', server.url, '--out', file]);
|
|
162
|
+
return { file, result };
|
|
163
|
+
},
|
|
164
|
+
compare: async (before, after) => (await spectra(['compare', before.join(','), after.join(',')])).comparison,
|
|
165
|
+
});
|
|
166
|
+
Object.assign(session, { kind: 'fix', spectraVersion: SPECTRA_VERSION, project: projectDir, branch, runs, startedAt: stamp, sessionDir });
|
|
167
|
+
|
|
168
|
+
for (const a of session.attempts) history[a.finding.key] = { title: a.finding.title, outcome: a.outcome, at: new Date().toISOString() };
|
|
169
|
+
fs.writeFileSync(historyFile, `${JSON.stringify(history, null, 2)}\n`);
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.error(`spectra: ${err.message}`);
|
|
172
|
+
if (err.tail) console.error(err.tail.replace(/^/gm, ' | '));
|
|
173
|
+
if (/cannot work/.test(err.message) && agent?.id === 'claude' && !opt('agent'))
|
|
174
|
+
console.error('spectra: sign in to Claude Code first (run `claude` once), set SPECTRA_CLAUDE to its path, or pass --agent <name|"command">.');
|
|
175
|
+
await cleanup();
|
|
176
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.off(signal, onSignal);
|
|
177
|
+
return EXIT.ERROR;
|
|
179
178
|
}
|
|
180
179
|
|
|
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
180
|
await cleanup();
|
|
210
|
-
process.
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
console.error('');
|
|
221
|
-
console.error(`
|
|
222
|
-
for (const
|
|
223
|
-
if (session.final) {
|
|
224
|
-
|
|
225
|
-
|
|
181
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.off(signal, onSignal);
|
|
182
|
+
const md = reportMarkdown(session);
|
|
183
|
+
fs.writeFileSync(path.join(sessionDir, 'report.md'), md);
|
|
184
|
+
fs.writeFileSync(path.join(sessionDir, 'report.json'), `${JSON.stringify(session, null, 2)}\n`);
|
|
185
|
+
if (opt('out') && typeof opt('out') === 'string') fs.writeFileSync(path.resolve(opt('out')), `${JSON.stringify(session, null, 2)}\n`);
|
|
186
|
+
|
|
187
|
+
const rel = p => path.relative(projectDir, p) || '.';
|
|
188
|
+
const n = session.attempts.length;
|
|
189
|
+
console.error('');
|
|
190
|
+
if (!n && !session.skipped.length) console.error("Spectra improve — nothing to fix: nothing in your own code crossed Spectra's thresholds.");
|
|
191
|
+
else console.error(`Spectra improve — ${session.kept} of ${n} problem${n === 1 ? '' : 's'} fixed`);
|
|
192
|
+
for (const a of session.attempts) console.error(` ${a.outcome === 'kept' ? '✓ fixed ' : '✗ undone '} ${a.finding.title} (${a.finding.file}${a.finding.line ? `:${a.finding.line}` : ''})${a.outcome === 'kept' ? `: ${a.reason}` : ` — ${a.reason}`}`);
|
|
193
|
+
for (const s of session.skipped) console.error(` – skipped ${s.title} — ${s.reason}`);
|
|
194
|
+
if (session.final) {
|
|
195
|
+
console.error(`\nOverall, before → after: ${session.final.verdict.toUpperCase()} (confidence: ${session.final.confidence})`);
|
|
196
|
+
for (const h of session.final.headlines) console.error(` ${h.kind === 'improved' ? '✓' : '✗'} ${h.text}`);
|
|
197
|
+
}
|
|
198
|
+
if (session.kept) console.error(`\nThe fixes are on branch ${branch}, one commit each, with the measurements in the commit messages.\nReview them with: git log -p HEAD..${branch}\nthen merge the branch or open a pull request. Your own files were not touched.`);
|
|
199
|
+
console.error(`\nReport: ${rel(path.join(sessionDir, 'report.md'))}`);
|
|
200
|
+
if (json) process.stdout.write(`${JSON.stringify(session)}\n`);
|
|
201
|
+
return EXIT.OK;
|
|
226
202
|
}
|
|
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
203
|
|
|
232
|
-
function branchExists(name) {
|
|
233
|
-
return spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { cwd:
|
|
204
|
+
function branchExists(root, name) {
|
|
205
|
+
return spawnSync('git', ['rev-parse', '--verify', '--quiet', `refs/heads/${name}`], { cwd: root }).status === 0;
|
|
234
206
|
}
|
|
235
|
-
function shortHead() {
|
|
236
|
-
return spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd:
|
|
207
|
+
function shortHead(root) {
|
|
208
|
+
return spawnSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, encoding: 'utf8' }).stdout.trim();
|
|
237
209
|
}
|
package/server/fix/policy.js
CHANGED
|
@@ -92,7 +92,9 @@ export function judge(c) {
|
|
|
92
92
|
if (c.verdict === 'regressed') return { decision: 'discard', outcome: 'slower', reason: `slower: ${worse[0] || 'a key metric got worse'}` };
|
|
93
93
|
if (c.verdict === 'mixed') return { decision: 'discard', outcome: 'mixed', reason: `mixed: ${worse[0] || 'something got worse'}` };
|
|
94
94
|
if (c.verdict !== 'improved') return { decision: 'discard', outcome: 'no-gain', reason: 'no measurable improvement: every change was within run-to-run noise' };
|
|
95
|
-
|
|
95
|
+
// a new critical problem in the project's own code counts; one in a dependency or a network
|
|
96
|
+
// resource (a vendor chunk the dev server renamed between runs) is not something the change made
|
|
97
|
+
const critical = c.findings.added.find(f => f.severity === 'critical' && ownFile(f));
|
|
96
98
|
if (critical) return { decision: 'discard', outcome: 'new-problem', reason: `introduced a critical finding: ${critical.title}` };
|
|
97
99
|
if (c.confidence === 'low') return { decision: 'measure-again', outcome: 'unproven', reason: 'improved, but with low confidence' };
|
|
98
100
|
return { decision: 'keep', outcome: 'kept', reason: better[0] || 'improved', headlines: better };
|
package/server/fix/test-fix.js
CHANGED
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { test } from 'node:test';
|
|
9
|
-
import { commandAgent } from './agent.js';
|
|
9
|
+
import { AGENTS, agentFromOption, commandAgent, detectAgent, findExecutable, namedAgent } from './agent.js';
|
|
10
10
|
import { detectChecks } from './checks.js';
|
|
11
11
|
import { runFix } from './harness.js';
|
|
12
12
|
import { fixPrompt, judge, rankFindings, scopeProblem } from './policy.js';
|
|
@@ -57,7 +57,10 @@ test('judges a change from the comparison', () => {
|
|
|
57
57
|
assert.equal(judge(comparison('neutral')).outcome, 'no-gain');
|
|
58
58
|
assert.equal(judge(comparison('regressed')).outcome, 'slower');
|
|
59
59
|
assert.equal(judge(comparison('mixed')).outcome, 'mixed');
|
|
60
|
-
assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'Long task' }] })).outcome, 'new-problem');
|
|
60
|
+
assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'Long task', file: 'src/App.jsx' }] })).outcome, 'new-problem');
|
|
61
|
+
// a new critical finding outside the project's code (a renamed vendor chunk, a request) is not the change's doing
|
|
62
|
+
assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'react-dom-Zq1x.js is 2.47 MB over the wire', file: null, culprits: [] }] })).decision, 'keep');
|
|
63
|
+
assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'big', file: 'node_modules/x/index.js' }] })).decision, 'keep');
|
|
61
64
|
});
|
|
62
65
|
|
|
63
66
|
test('tells the agent the finding and the rules', () => {
|
|
@@ -225,3 +228,41 @@ test('finds checks in package.json and runs any agent command', async () => {
|
|
|
225
228
|
assert.equal(fs.readFileSync(path.join(root, 'app/src/Row.jsx'), 'utf8'), '// Finding: Row is slow\n');
|
|
226
229
|
fs.rmSync(root, { recursive: true, force: true });
|
|
227
230
|
});
|
|
231
|
+
|
|
232
|
+
test('finds the coding agent on the machine: the one in use first, then any installed; runs it by name', async () => {
|
|
233
|
+
const bin = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-agents-bin-'));
|
|
234
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-agents-home-'));
|
|
235
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-agents-root-'));
|
|
236
|
+
const fake = (name, body = 'echo "done: $*"') => {
|
|
237
|
+
fs.writeFileSync(path.join(bin, name), `#!/bin/sh\n${body}\n`);
|
|
238
|
+
fs.chmodSync(path.join(bin, name), 0o755);
|
|
239
|
+
};
|
|
240
|
+
const env = { PATH: bin };
|
|
241
|
+
assert.equal(detectAgent({ root, home, env }), null, 'nothing installed');
|
|
242
|
+
fake('codex');
|
|
243
|
+
fake('gemini');
|
|
244
|
+
assert.equal(detectAgent({ root, home, env }).id, 'codex', 'the first installed one, in the order of AGENTS');
|
|
245
|
+
fs.mkdirSync(path.join(home, '.gemini'));
|
|
246
|
+
assert.equal(detectAgent({ root, home, env }).id, 'gemini', 'the one the machine shows signs of');
|
|
247
|
+
fs.mkdirSync(path.join(root, '.codex'));
|
|
248
|
+
assert.equal(detectAgent({ root, home, env }).id, 'codex', 'the one the project shows signs of');
|
|
249
|
+
assert.equal(findExecutable('gemini', { env, home }), path.join(bin, 'gemini'));
|
|
250
|
+
assert.equal(findExecutable('claude', { env: { PATH: bin, SPECTRA_CLAUDE: '/x/claude' }, home }), '/x/claude', 'SPECTRA_CLAUDE wins');
|
|
251
|
+
fs.mkdirSync(path.join(home, '.local/bin'), { recursive: true });
|
|
252
|
+
fs.writeFileSync(path.join(home, '.local/bin/claude'), '');
|
|
253
|
+
assert.equal(findExecutable('claude', { env, home }), path.join(home, '.local/bin/claude'), "the installer's folder, off PATH");
|
|
254
|
+
|
|
255
|
+
// a named agent gets the prompt the way its CLI takes it, and its last line is the summary
|
|
256
|
+
const gemini = namedAgent('gemini', { env, home });
|
|
257
|
+
assert.equal(gemini.name, 'Gemini CLI');
|
|
258
|
+
const res = await gemini.run('Fix one thing.', { cwd: root });
|
|
259
|
+
assert.equal(res.summary, 'done: -p Fix one thing. --yolo');
|
|
260
|
+
fake('codex', 'echo "boom" >&2; exit 3');
|
|
261
|
+
await assert.rejects(namedAgent('codex', { env, home }).run('x', { cwd: root }), /Codex CLI exited with code 3: boom/);
|
|
262
|
+
await assert.rejects(namedAgent('amp', { env, home }).run('x', { cwd: root }), /could not start Amp/);
|
|
263
|
+
assert.equal(agentFromOption('./my-agent.sh').name, './my-agent.sh', 'anything else is a command');
|
|
264
|
+
assert.equal(agentFromOption('claude', { env, home }).id, 'claude');
|
|
265
|
+
assert.throws(() => namedAgent('nope'), /unknown agent "nope"/);
|
|
266
|
+
assert.deepEqual(Object.keys(AGENTS).slice(0, 3), ['claude', 'codex', 'gemini']);
|
|
267
|
+
for (const d of [bin, home, root]) fs.rmSync(d, { recursive: true, force: true });
|
|
268
|
+
});
|