@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.
@@ -0,0 +1,304 @@
1
+ /**
2
+ * spectra improve — make the app faster. Two ways, with the same rules:
3
+ *
4
+ * spectra improve unattended: a coding agent on this machine makes
5
+ * the changes in a copy of the project, and only
6
+ * what passes the checks and measures faster is
7
+ * kept, as commits on a new branch (../fix/)
8
+ * spectra improve next guided: Spectra measures and names the next
9
+ * spectra improve check problem; an AI agent (or you) changes the code in
10
+ * spectra improve report the working tree; Spectra measures the change and
11
+ * spectra improve stop says whether to keep it (./session.js)
12
+ *
13
+ * Without an agent on the machine, `spectra improve` starts a guided session.
14
+ */
15
+ import { spawn } from 'node:child_process';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { intOption, parseArgs } from '../args.js';
20
+ import { bootDevServer } from '../explore/boot.js';
21
+ import { EXIT, SPECTRA_VERSION, exitAfterFlush } from '../output.js';
22
+ import { AGENT_IDS, agentFromOption, detectAgent } from '../fix/agent.js';
23
+ import { detectChecks, runChecks } from '../fix/checks.js';
24
+ import { ignoreSpectra, repoOf } from '../fix/workspace.js';
25
+ import { checkTask, checkText, nextTask, overall, reportMarkdown, reportText, startSession, taskText } from './session.js';
26
+ import { RESULT_SCHEMA_VERSION } from '../../shared/result.js';
27
+
28
+ const BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../bin/spectra.js');
29
+ const STEPS = ['next', 'check', 'report', 'stop'];
30
+ const VALUES = ['url', 'runs', 'max-fixes', 'check', 'command', 'boot-timeout', 'config', 'login-url', 'login-user', 'login', 'throttle', 'agent', 'model', 'max-budget-usd', 'branch', 'max-lines', 'out'];
31
+ // options the profiling runs take as they are (how to reach the page, and how to sign in)
32
+ const PASS_THROUGH = ['config', 'login-url', 'login-user', 'login', 'throttle'];
33
+
34
+ export const HELP = `usage: spectra improve [options]
35
+ spectra improve next | check | report | stop [options]
36
+
37
+ Makes the app faster: measures it, finds the problems in your own code, has
38
+ each one fixed, measures again, and keeps only what measurably helped.
39
+
40
+ Unattended (spectra improve): a coding agent on this machine makes the
41
+ changes in a copy of your last commit. Kept fixes are commits on a new
42
+ branch, for you to review. Spectra finds the agent by itself
43
+ (${AGENT_IDS.join(', ')}), or take --agent.
44
+
45
+ Guided (next, check, report, stop): for the AI agent you are talking to, or
46
+ for you. "next" measures and names the next problem; after the change,
47
+ "check" measures it and says whether to keep it, then names the next one;
48
+ "report" sums up; "stop" ends the session.
49
+
50
+ --url <page> The page to make faster (default: the dev server's home page,
51
+ started and stopped by Spectra)
52
+ --max-fixes <n> Problems to attempt (default 3 unattended, 5 guided)
53
+ --runs <n> Page loads per measurement (default 3)
54
+ --check "<cmd>" A check that must pass after every change (repeat it
55
+ for several). Default: your test, typecheck and
56
+ build scripts. Checks can test the running app at
57
+ $SPECTRA_FIX_URL
58
+ --no-checks Rely on measurements alone (not recommended)
59
+ --command "<cmd>" The dev-server command (default: your npm dev script)
60
+ --boot-timeout <s> How long to wait for the dev server (default 60)
61
+ --login-url, --login-user, --login <method>, --config <file>
62
+ Sign in first, as for spectra performance
63
+ --json, --out <file> Machine-readable output
64
+
65
+ unattended only:
66
+ --agent <name|"cmd"> ${AGENT_IDS.join(', ')}, or your own
67
+ command (it gets the instructions on stdin)
68
+ --model <name> Model for Claude Code
69
+ --max-budget-usd <n> Claude Code's spending cap per finding (default 2)
70
+ --install Install dependencies in the copy instead of linking yours
71
+ --branch <name> Branch for the fixes (default spectra/improve-<time>)
72
+ --max-lines <n> Largest change accepted (default 150 lines)
73
+ --retry Try problems that failed in an earlier session
74
+ --keep-worktree Keep the copy in .spectra/improve/ for inspection
75
+
76
+ guided only:
77
+ --restart End the current session and start over (with next)`;
78
+
79
+ export async function runImproveCli(argv) {
80
+ const { opt, flag, positionals } = parseArgs(argv, { values: VALUES });
81
+ if (flag('help') || flag('h')) {
82
+ console.log(HELP);
83
+ return EXIT.OK;
84
+ }
85
+ const step = positionals.find(p => STEPS.includes(p));
86
+ if (positionals.length && !step) {
87
+ console.error(`spectra: improve takes one of ${STEPS.join(', ')} (got "${positionals[0]}"). See spectra improve --help.`);
88
+ return EXIT.ERROR;
89
+ }
90
+ if (step) return guided(step, argv, { opt, flag });
91
+
92
+ // unattended, with the agent named or found; without one, the guided mode's first step
93
+ const agentOpts = { model: opt('model') === true ? undefined : opt('model'), budgetUsd: Number(opt('max-budget-usd') ?? 2) };
94
+ let agent = null;
95
+ try {
96
+ agent = opt('agent') ? agentFromOption(String(opt('agent')), agentOpts) : (found => found && agentFromOption(found.id, { ...agentOpts, bin: found.bin }))(detectAgent());
97
+ } catch (err) {
98
+ console.error(`spectra: ${err.message}`);
99
+ return EXIT.ERROR;
100
+ }
101
+ if (agent) {
102
+ const { runFixCli } = await import('../fix/cli.js');
103
+ return runFixCli(argv, { agent });
104
+ }
105
+ console.error(`spectra: no coding agent found on this machine (${AGENT_IDS.join(', ')}), so Spectra cannot make the changes unattended.`);
106
+ console.error('spectra: starting a guided session instead: ask your AI agent to "improve the performance", or make each change yourself.\n');
107
+ return guided('next', argv, { opt, flag });
108
+ }
109
+
110
+ // ------------------------------------------------------------------ guided
111
+
112
+ const stamp = () => new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
113
+
114
+ async function guided(step, argv, { opt, flag }) {
115
+ const json = flag('json');
116
+ const log = json ? console.error : console.log; // with --json, stdout carries only the result
117
+ const status = line => console.error(`spectra: ${line}`);
118
+ const projectDir = process.cwd();
119
+ const root = path.join(projectDir, '.spectra', 'improve');
120
+ const currentFile = path.join(root, 'current.json');
121
+ const historyFile = path.join(root, 'history.json');
122
+ const readJson = (file, fallback) => {
123
+ try {
124
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
125
+ } catch {
126
+ return fallback;
127
+ }
128
+ };
129
+ const save = session => fs.writeFileSync(currentFile, `${JSON.stringify(session, null, 2)}\n`);
130
+ try {
131
+ ignoreSpectra(repoOf(projectDir).root);
132
+ } catch {
133
+ /* not a git repository: fine for the guided mode */
134
+ }
135
+
136
+ let runs;
137
+ let maxFixes;
138
+ try {
139
+ runs = intOption(opt('runs'), 'runs', { min: 1, max: 10, fallback: 3 });
140
+ maxFixes = intOption(opt('max-fixes'), 'max-fixes', { min: 1, max: 20, fallback: 5 });
141
+ } catch (err) {
142
+ console.error(`spectra: ${err.message}`);
143
+ return EXIT.ERROR;
144
+ }
145
+
146
+ let session = readJson(currentFile, null);
147
+ if (step === 'next' && flag('restart') && session) {
148
+ await finish(session, { archive: true });
149
+ session = null;
150
+ }
151
+
152
+ // the result for --json / --out, and the text for people and agents
153
+ const emit = (message, extra = {}) => {
154
+ log(message);
155
+ const result = { schemaVersion: RESULT_SCHEMA_VERSION, kind: 'improve', spectraVersion: SPECTRA_VERSION, createdAt: new Date().toISOString(), step, message, ...extra, session };
156
+ if (opt('out') && typeof opt('out') === 'string') fs.writeFileSync(path.resolve(opt('out')), `${JSON.stringify(result, null, 2)}\n`);
157
+ if (json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
158
+ };
159
+ const total = s => s.attempts.length + (s.task ? 1 : 0) + s.queue.length;
160
+
161
+ // ---- the dev server, for the commands that measure
162
+ const active = { server: null, child: null };
163
+ const stopServer = async () => {
164
+ const s = active.server;
165
+ active.server = null;
166
+ if (s) {
167
+ await s.stop().catch(() => {});
168
+ status('stopped the dev server.');
169
+ }
170
+ };
171
+ for (const signal of ['SIGINT', 'SIGTERM'])
172
+ process.on(signal, () => {
173
+ active.child?.kill('SIGTERM');
174
+ stopServer().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
175
+ });
176
+ const pageUrl = async s => {
177
+ if (s.fixedUrl) return s.fixedUrl;
178
+ if (!active.server) {
179
+ const command = s.command || (typeof opt('command') === 'string' ? opt('command') : undefined);
180
+ status(`starting the dev server in ${projectDir}${command ? ` (${command})` : ''} …`);
181
+ active.server = await bootDevServer({ root: projectDir, command, timeoutMs: (Number(s.bootTimeout || opt('boot-timeout')) || 60) * 1000 });
182
+ status(`dev server ready at ${active.server.url}`);
183
+ }
184
+ return active.server.url;
185
+ };
186
+
187
+ /** `spectra <args> --json`, in the project; resolves with the parsed result. */
188
+ const spectra = (args, dir) =>
189
+ new Promise((resolve, reject) => {
190
+ active.child = spawn(process.execPath, [BIN, ...args, '--json'], {
191
+ cwd: projectDir,
192
+ env: { ...process.env, PERF_FRONTEND_DIR: projectDir, PERF_REPORTS_DIR: path.join(dir, 'reports') },
193
+ stdio: ['ignore', 'pipe', 'pipe'],
194
+ });
195
+ let out = '';
196
+ let err = '';
197
+ active.child.stdout.on('data', d => (out += d));
198
+ active.child.stderr.on('data', d => {
199
+ err += d;
200
+ for (const line of String(d).split('\n')) if (/^spectra: run \d+\/\d+/.test(line)) console.error(` ${line.replace(/^spectra:\s*/, '')}`);
201
+ });
202
+ active.child.on('error', reject);
203
+ active.child.on('close', code => {
204
+ active.child = null;
205
+ if (code === 1) return reject(new Error(err.trim().split('\n').filter(l => l.startsWith('spectra:')).pop()?.replace(/^spectra:\s*/, '') || `spectra ${args[0]} failed`));
206
+ try {
207
+ resolve(JSON.parse(out));
208
+ } catch {
209
+ reject(new Error(`spectra ${args[0]} printed no result`));
210
+ }
211
+ });
212
+ });
213
+ const passThrough = s => s.profileArgs || [];
214
+ const deps = s => ({
215
+ profile: async label => {
216
+ const url = await pageUrl(s);
217
+ const file = path.join(s.dir, 'results', `${label.replace(/[^a-z0-9]+/gi, '-').toLowerCase()}-${Date.now()}.json`);
218
+ const result = await spectra(['performance', '--quick', '--runs', String(s.runs), '--url', url, ...passThrough(s), '--out', file], s.dir);
219
+ return { file, result };
220
+ },
221
+ compare: async (before, after) => (await spectra(['compare', before.join(','), after.join(',')], s.dir)).comparison,
222
+ runChecks: s.checkCommands?.length
223
+ ? async () => runChecks(s.checkCommands, { cwd: projectDir, env: { SPECTRA_FIX_URL: await pageUrl(s) } })
224
+ : null,
225
+ log: status,
226
+ });
227
+
228
+ async function finish(s, { archive }) {
229
+ const result = await overall(s, deps(s).compare).catch(() => null);
230
+ fs.writeFileSync(path.join(s.dir, 'report.md'), reportMarkdown(s, result));
231
+ fs.writeFileSync(path.join(s.dir, 'session.json'), `${JSON.stringify(s, null, 2)}\n`);
232
+ if (archive) fs.rmSync(currentFile, { force: true });
233
+ return result;
234
+ }
235
+ const remember = s => {
236
+ const history = readJson(historyFile, {});
237
+ for (const a of s.attempts) history[a.finding.key] = { title: a.finding.title, outcome: a.outcome, at: new Date().toISOString() };
238
+ fs.writeFileSync(historyFile, `${JSON.stringify(history, null, 2)}\n`);
239
+ };
240
+
241
+ try {
242
+ if (step === 'next') {
243
+ if (!session) {
244
+ const dir = path.join(root, stamp());
245
+ fs.mkdirSync(path.join(dir, 'results'), { recursive: true });
246
+ // the checks: --check, or the project's own; none is allowed here, the measurements then decide alone
247
+ let checkCommands = [];
248
+ for (let i = 0; i < argv.length; i++) if (argv[i] === '--check' && argv[i + 1]) checkCommands.push({ name: argv[i + 1], cmd: argv[++i] });
249
+ if (!checkCommands.length && !flag('no-checks')) checkCommands = detectChecks(projectDir);
250
+ const meta = {
251
+ id: path.basename(dir),
252
+ dir,
253
+ runs,
254
+ fixedUrl: typeof opt('url') === 'string' ? opt('url') : null,
255
+ command: typeof opt('command') === 'string' ? opt('command') : null,
256
+ bootTimeout: Number(opt('boot-timeout')) || null,
257
+ profileArgs: PASS_THROUGH.flatMap(name => (typeof opt(name) === 'string' ? [`--${name}`, name === 'config' ? path.resolve(opt(name)) : opt(name)] : [])),
258
+ checkCommands,
259
+ };
260
+ status(`measuring the baseline (${runs} page loads) …`);
261
+ session = await startSession({ ...deps(meta), exists: f => fs.existsSync(path.join(projectDir, f)), history: readJson(historyFile, {}), retry: flag('retry'), maxFixes, checks: checkCommands, meta });
262
+ if (!checkCommands.length) status('no checks found (no test, typecheck or build script): the measurements decide alone.');
263
+ }
264
+ const task = nextTask(session);
265
+ save(session);
266
+ if (!task) {
267
+ const result = await finish(session, { archive: false });
268
+ emit(`${reportText(session, result)}\n\nNothing left to try in this session. Run: npx spectra improve next --restart to measure again from the start.`, { task: null, result });
269
+ } else emit(taskText(task, { total: total(session) }), { task });
270
+ } else if (step === 'check') {
271
+ if (!session) throw new Error('no session: run `npx spectra improve next` first.');
272
+ const { attempt, next } = await checkTask(session, deps(session));
273
+ save(session);
274
+ remember(session);
275
+ emit(checkText(attempt, next, { total: total(session) }), { attempt, task: next });
276
+ } else if (step === 'report') {
277
+ if (!session) throw new Error('no session: run `npx spectra improve next` first.');
278
+ const result = await finish(session, { archive: false });
279
+ emit(`${reportText(session, result)}\n\nReport: ${path.relative(projectDir, path.join(session.dir, 'report.md'))}`, { result });
280
+ } else {
281
+ if (!session) throw new Error('no session to stop.');
282
+ const result = await finish(session, { archive: true });
283
+ remember(session);
284
+ emit(`${reportText(session, result)}\n\nSession ended. Report: ${path.relative(projectDir, path.join(session.dir, 'report.md'))}`, { result });
285
+ }
286
+ } catch (err) {
287
+ console.error(`spectra: ${err.message}`);
288
+ await stopServer();
289
+ return EXIT.ERROR;
290
+ }
291
+ await stopServer();
292
+ return EXIT.OK;
293
+ }
294
+
295
+ /** Entry point for bin/spectra.js: runs, then exits once stdout is flushed. */
296
+ export async function main(argv) {
297
+ let code = EXIT.ERROR;
298
+ try {
299
+ code = await runImproveCli(argv);
300
+ } catch (err) {
301
+ console.error(`spectra: ${err.message}`);
302
+ }
303
+ exitAfterFlush(code);
304
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The guided mode of `spectra improve`: Spectra measures and decides, and
3
+ * whoever is at the keyboard (an AI agent, or a person) edits the code in the
4
+ * working tree.
5
+ *
6
+ * next measure, when nothing is measured yet; then the next problem to fix
7
+ * check measure the change just made: kept, or to be undone; then the next problem
8
+ * report what has happened so far
9
+ * stop end the session
10
+ *
11
+ * The session is a JSON file, so each command is one short run and the state
12
+ * survives between them. Everything that measures, compares or runs checks is
13
+ * passed in, so the logic is tested without a browser (test-improve.js). The
14
+ * rules (which findings, when a change is kept) are the unattended mode's,
15
+ * from ../fix/policy.js, so both modes decide the same way.
16
+ */
17
+ import { findingKey } from '../../shared/compare.js';
18
+ import { judge, rankFindings } from '../fix/policy.js';
19
+
20
+ export const SESSION_VERSION = 1;
21
+
22
+ const where = f => `${f.file}${f.line ? `:${f.line}` : ''}`;
23
+ const ms = v => (v == null ? '—' : v >= 1000 ? `${(v / 1000).toFixed(2)} s` : `${Math.round(v)} ms`);
24
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
25
+
26
+ /**
27
+ * Starts a session: measures the baseline and queues the problems worth a
28
+ * try. `o.profile(label)` resolves with { file, result }; `o.exists(file)`
29
+ * says whether a finding's file is in the project.
30
+ */
31
+ export async function startSession(o) {
32
+ const { profile, exists, history = {}, retry = false, maxFixes = 5, checks = [], meta = {} } = o;
33
+ const baseline = await profile('baseline');
34
+ const { candidates, skipped } = rankFindings(baseline.result.findings, { exists, history, retry, max: maxFixes });
35
+ return {
36
+ version: SESSION_VERSION,
37
+ ...meta,
38
+ startedAt: new Date().toISOString(),
39
+ url: baseline.result.url,
40
+ checks: checks.map(c => c.name),
41
+ baseline: { file: baseline.file, summary: baseline.result.summary, findings: baseline.result.findings.length },
42
+ current: [baseline.file],
43
+ queue: candidates.map(f => ({ key: f.key, title: f.title, file: f.file, line: f.line, severity: f.severity, impactMs: f.impactMs, detail: f.detail, fix: f.fix, culprits: f.culprits, seenIn: f.seenIn })),
44
+ task: null,
45
+ attempts: [],
46
+ skipped: skipped.map(s => ({ title: s.finding.title, reason: s.reason })),
47
+ kept: 0,
48
+ };
49
+ }
50
+
51
+ /** The problem to work on: the one handed out already, or the next in the queue. Null when there is none left. */
52
+ export function nextTask(session) {
53
+ if (!session.task && session.queue.length) session.task = { ...session.queue.shift(), number: session.attempts.length + 1 };
54
+ return session.task;
55
+ }
56
+
57
+ /**
58
+ * Measures the change made for the current task and decides: kept, or to be
59
+ * undone. The task is done either way; the next one is handed out. Resolves
60
+ * with the attempt (outcome, reason, headlines, checks) and the next task.
61
+ */
62
+ export async function checkTask(session, o) {
63
+ const { profile, compare, runChecks, log = () => {} } = o;
64
+ const task = session.task;
65
+ if (!task) throw new Error('nothing to check: run `spectra improve next` first, make the change it describes, then check it.');
66
+ const attempt = { finding: { title: task.title, file: task.file, line: task.line, severity: task.severity, impactMs: task.impactMs, key: task.key }, outcome: null, reason: '', headlines: [], checks: [] };
67
+ const done = (outcome, reason, extra = {}) => {
68
+ Object.assign(attempt, { outcome, reason, ...extra });
69
+ session.attempts.push(attempt);
70
+ session.task = null;
71
+ return { attempt, next: nextTask(session) };
72
+ };
73
+
74
+ if (session.checks.length && runChecks) {
75
+ log(`running the checks (${session.checks.join(', ')}) …`);
76
+ const results = await runChecks();
77
+ attempt.checks = results.map(r => ({ name: r.name, ok: r.ok }));
78
+ const failed = results.find(r => !r.ok);
79
+ if (failed) return done('checks-failed', `"${failed.name}" failed`, { checkTail: failed.tail });
80
+ }
81
+
82
+ log('measuring the change …');
83
+ const after = [await profile(`after ${attempt.finding.key}`)];
84
+ let comparison = await compare(session.current, after.map(a => a.file));
85
+ let verdict = judge(comparison);
86
+ if (verdict.decision === 'measure-again') {
87
+ log('improved with low confidence; measuring again …');
88
+ after.push(await profile(`after ${attempt.finding.key}, more runs`));
89
+ comparison = await compare(session.current, after.map(a => a.file));
90
+ verdict = judge(comparison);
91
+ if (verdict.decision === 'measure-again') verdict = { decision: 'discard', outcome: 'unproven', reason: 'not proven: still low confidence after more runs' };
92
+ }
93
+ attempt.comparison = { verdict: comparison.verdict, confidence: comparison.confidence, resolved: comparison.findings.resolved.map(x => x.title), added: comparison.findings.added.map(x => x.title) };
94
+ if (verdict.decision !== 'keep') return done(verdict.outcome, verdict.reason);
95
+
96
+ session.current = after.map(a => a.file);
97
+ session.kept++;
98
+ // one fix often clears several problems: drop the queued ones the new measurement no longer shows
99
+ const present = new Set(after.flatMap(a => a.result.findings || []).map(findingKey));
100
+ const gone = session.queue.filter(f => !present.has(f.key));
101
+ session.queue = session.queue.filter(f => present.has(f.key));
102
+ for (const f of gone) session.skipped.push({ title: f.title, reason: 'already resolved by an earlier kept change' });
103
+ return done('kept', verdict.reason, { headlines: verdict.headlines });
104
+ }
105
+
106
+ /** The session's before → after, when something was kept; needs `compare` (no browser). */
107
+ export async function overall(session, compare) {
108
+ if (!session.kept) return null;
109
+ const c = await compare([session.baseline.file], session.current);
110
+ return { verdict: c.verdict, confidence: c.confidence, headlines: c.headlines, resolved: c.findings.resolved.map(x => x.title) };
111
+ }
112
+
113
+ // ------------------------------------------------------------------ words
114
+
115
+ /** The task, as instructions for whoever makes the change. */
116
+ export function taskText(task, { total, command = 'npx spectra improve check' }) {
117
+ const culprits = (task.culprits || []).slice(0, 4).map(c => ` ${c.fn || '(anonymous)'}()${c.file ? ` in ${c.file}${c.line ? `:${c.line}` : ''}` : ''}${c.ms ? `: ${ms(c.ms)}` : ''}${c.note ? ` (${c.note})` : ''}`);
118
+ return [
119
+ `Problem ${task.number} of ${total}: ${task.title}`,
120
+ ` Where: ${where(task)}`,
121
+ ` Cost: about ${ms(task.impactMs)}${task.seenIn ? `, seen in ${task.seenIn} runs` : ''}`,
122
+ ...(task.detail ? [` Detail: ${task.detail}`] : []),
123
+ ...(culprits.length ? [' Where the time went:', ...culprits] : []),
124
+ ...(task.fix ? [` Suggested fix: ${task.fix}`] : []),
125
+ '',
126
+ `Make ONE focused change in ${task.file} that removes or reduces this cost, keeping the behaviour exactly the same:`,
127
+ 'the same output, UI and data. Edit only the source files involved (not package.json, lockfiles, configuration,',
128
+ 'tests or CI), keep the change small, and do not delete features or hide content to look faster.',
129
+ `Then run: ${command}`,
130
+ ].join('\n');
131
+ }
132
+
133
+ const OUTCOME = {
134
+ kept: 'fixed',
135
+ 'checks-failed': 'undone: a check failed',
136
+ 'no-gain': 'undone: no measurable gain',
137
+ slower: 'undone: slower',
138
+ mixed: 'undone: mixed',
139
+ 'new-problem': 'undone: new problem',
140
+ unproven: 'undone: not proven',
141
+ };
142
+
143
+ /** What happened to the change just checked, and what to do now. */
144
+ export function checkText(attempt, next, { total, command = 'npx spectra improve check', reportCommand = 'npx spectra improve report' }) {
145
+ const lines = [];
146
+ if (attempt.outcome === 'kept') {
147
+ lines.push(`✓ Kept: ${attempt.reason}`, ...attempt.headlines.slice(1).map(h => ` also ${h}`));
148
+ if (attempt.comparison?.resolved.length) lines.push(` Resolved: ${attempt.comparison.resolved.slice(0, 3).join('; ')}`);
149
+ if (attempt.checks.length) lines.push(` Checks passed: ${attempt.checks.map(c => c.name).join(', ')}.`);
150
+ lines.push(' Keep this change.');
151
+ } else {
152
+ lines.push(`✗ Undo this change: ${attempt.reason}.`);
153
+ if (attempt.outcome === 'checks-failed' && attempt.checkTail) lines.push(...attempt.checkTail.split('\n').slice(-6).map(l => ` | ${l}`));
154
+ lines.push(' Revert the files you changed for this problem (and only those), then continue.');
155
+ }
156
+ lines.push('');
157
+ if (next) lines.push(taskText(next, { total, command }));
158
+ else lines.push(`No more problems to try. Run: ${reportCommand}`);
159
+ return lines.join('\n');
160
+ }
161
+
162
+ /** The session so far, in plain words. */
163
+ export function reportText(session, result, { workingTree = true } = {}) {
164
+ const tried = session.attempts.length;
165
+ const lines = [`Spectra improve — ${session.kept} of ${plural(tried, 'problem')} fixed${session.task ? ` (one being worked on)` : ''}${session.queue.length ? `, ${session.queue.length} still to try` : ''}`];
166
+ for (const a of session.attempts) lines.push(` ${a.outcome === 'kept' ? '✓' : '✗'} ${(OUTCOME[a.outcome] || a.outcome).padEnd(28)} ${a.finding.title} (${where(a.finding)})${a.outcome === 'kept' ? `: ${a.reason}` : a.reason ? ` — ${a.reason}` : ''}`);
167
+ if (session.task) lines.push(` … in progress ${session.task.title} (${where(session.task)})`);
168
+ if (result) {
169
+ lines.push('', `Overall, before → after: ${result.verdict.toUpperCase()} (confidence: ${result.confidence})`, ...result.headlines.map(h => ` ${h.kind === 'improved' ? '✓' : '✗'} ${h.text}`));
170
+ } else if (!tried) lines.push('', session.queue.length ? `${plural(session.queue.length, 'problem')} found in your own code. Run: npx spectra improve next` : 'Nothing in your own code crossed Spectra\'s thresholds: nothing to fix.');
171
+ if (session.skipped.length) lines.push('', 'Not attempted:', ...session.skipped.slice(0, 6).map(s => ` – ${s.title}: ${s.reason}`));
172
+ if (session.kept && workingTree) lines.push('', 'The kept changes are in your working tree, uncommitted. Review them with `git diff`, then commit.');
173
+ return lines.join('\n');
174
+ }
175
+
176
+ /** The report as Markdown, for a pull request. */
177
+ export function reportMarkdown(session, result) {
178
+ const lines = [`## ⚡ Spectra improve: ${session.kept} of ${plural(session.attempts.length, 'problem')} fixed`, '', `Page: ${session.url} · checks: ${session.checks.length ? session.checks.join(', ') : 'none'}`, ''];
179
+ if (result) lines.push(`**Overall, before → after:** ${result.verdict} (confidence: ${result.confidence})`, '', ...result.headlines.map(h => `- ${h.kind === 'improved' ? '✅' : '❌'} ${h.text}`), '');
180
+ if (session.attempts.length) {
181
+ lines.push('| Problem | Where | Outcome | Why |', '| --- | --- | --- | --- |');
182
+ for (const a of session.attempts) lines.push(`| ${a.finding.title.replace(/\|/g, '\\|')} | \`${where(a.finding)}\` | ${a.outcome === 'kept' ? '✅ fixed' : `❌ ${OUTCOME[a.outcome] || a.outcome}`} | ${(a.reason || '').replace(/\|/g, '\\|')} |`);
183
+ lines.push('');
184
+ }
185
+ if (session.skipped.length) lines.push('Not attempted:', ...session.skipped.map(s => `- ${s.title}: ${s.reason}`), '');
186
+ lines.push('<sub>Every kept change measured faster beyond run-to-run noise. Review it like any other change.</sub>');
187
+ return `${lines.join('\n')}\n`;
188
+ }
@@ -0,0 +1,135 @@
1
+ // The guided `spectra improve` session without a browser: measurements and
2
+ // comparisons are fakes, so what is tested is what Spectra decides and says.
3
+ import assert from 'node:assert/strict';
4
+ import { test } from 'node:test';
5
+ import { checkTask, checkText, nextTask, overall, reportMarkdown, reportText, startSession, taskText } from './session.js';
6
+
7
+ const finding = (title, file, extra = {}) => ({ id: `${title}-${file}`, severity: 'critical', title, impactMs: 500, file, line: 3, fix: 'memoize it', culprits: [{ fn: 'Row', file, line: 3, ms: 500 }], ...extra });
8
+ const own = [finding('Row is slow to render', 'src/Row.jsx', { impactMs: 1400 }), finding('heavy.js spent 586ms', 'src/heavy.js', { impactMs: 586 }), finding('App rendered 60×', 'src/App.jsx', { impactMs: 90, severity: 'warning' })];
9
+ const vendor = finding('react-dom is big', 'node_modules/react-dom/index.js');
10
+ const result = (findings, file = 'r.json') => ({ file, result: { url: 'http://localhost:5190/', summary: { tbtMs: 10 }, findings } });
11
+ const comparison = (verdict, confidence = 'medium', extra = {}) => ({
12
+ verdict,
13
+ confidence,
14
+ headlines: verdict === 'improved' ? [{ kind: 'improved', text: 'JavaScript CPU time: 1.55 s → 107 ms (−93%)' }, { kind: 'improved', text: 'Row — render time: 1.44 s → 8 ms' }] : verdict === 'neutral' ? [] : [{ kind: 'degraded', text: 'Total blocking time: 0 ms → 300 ms' }],
15
+ findings: { added: [], resolved: [], persisting: [], ...extra },
16
+ });
17
+
18
+ async function session(opts = {}) {
19
+ const calls = [];
20
+ const s = await startSession({
21
+ profile: async label => {
22
+ calls.push(label);
23
+ return result([...own, vendor], 'baseline.json');
24
+ },
25
+ exists: f => !f.includes('Deleted'),
26
+ checks: [{ name: 'npm test', cmd: 'npm test' }],
27
+ meta: { id: 's1', dir: '/tmp/s1', runs: 3 },
28
+ ...opts,
29
+ });
30
+ return { s, calls };
31
+ }
32
+
33
+ test('a session measures once, then queues the problems in the project\'s own code, costliest first', async () => {
34
+ const { s, calls } = await session();
35
+ assert.deepEqual(calls, ['baseline']);
36
+ assert.equal(s.baseline.file, 'baseline.json');
37
+ assert.deepEqual(s.current, ['baseline.json']);
38
+ assert.deepEqual(
39
+ s.queue.map(q => q.title),
40
+ ['Row is slow to render', 'heavy.js spent 586ms', 'App rendered 60×'],
41
+ );
42
+ assert.equal(s.task, null);
43
+ assert.equal(s.kept, 0);
44
+ assert.deepEqual(s.checks, ['npm test']);
45
+ // the same task until it is checked
46
+ const task = nextTask(s);
47
+ assert.equal(task.title, 'Row is slow to render');
48
+ assert.equal(task.number, 1);
49
+ assert.equal(nextTask(s), task);
50
+ assert.equal(s.queue.length, 2);
51
+ const text = taskText(task, { total: 3 });
52
+ assert.match(text, /^Problem 1 of 3: Row is slow to render/);
53
+ assert.match(text, /Where: src\/Row\.jsx:3/);
54
+ assert.match(text, /Suggested fix: memoize it/);
55
+ assert.match(text, /npx spectra improve check/);
56
+ });
57
+
58
+ test('a kept change moves the baseline on, and drops the queued problems it already solved', async () => {
59
+ const { s } = await session();
60
+ nextTask(s);
61
+ const log = [];
62
+ const { attempt, next } = await checkTask(s, {
63
+ profile: async label => result([own[2]], `${label}.json`), // the slow row and heavy.js are gone
64
+ compare: async (before, after) => {
65
+ log.push([before, after]);
66
+ return comparison('improved', 'high', { resolved: [{ title: 'Row is slow to render' }] });
67
+ },
68
+ runChecks: async () => [{ name: 'npm test', ok: true }],
69
+ });
70
+ assert.equal(attempt.outcome, 'kept');
71
+ assert.match(attempt.reason, /JavaScript CPU time/);
72
+ assert.deepEqual(attempt.checks, [{ name: 'npm test', ok: true }]);
73
+ assert.deepEqual(log, [[['baseline.json'], ['after Row is slow to render-src/Row.jsx.json']]]);
74
+ assert.equal(s.kept, 1);
75
+ assert.deepEqual(s.current, ['after Row is slow to render-src/Row.jsx.json']);
76
+ assert.equal(s.task, next);
77
+ assert.equal(next.title, 'App rendered 60×', 'heavy.js was resolved by the kept change, so it is skipped');
78
+ assert.equal(next.number, 2);
79
+ assert.match(s.skipped[0].reason, /already resolved/);
80
+ const text = checkText(attempt, next, { total: 3 });
81
+ assert.match(text, /^✓ Kept: JavaScript CPU time/);
82
+ assert.match(text, /also Row — render time/);
83
+ assert.match(text, /Keep this change\./);
84
+ assert.match(text, /Problem 2 of 3: App rendered 60×/);
85
+ });
86
+
87
+ test('a change that measures no better is to be undone; a failing check stops it before measuring', async () => {
88
+ const { s } = await session();
89
+ nextTask(s);
90
+ let profiled = 0;
91
+ const noGain = await checkTask(s, { profile: async () => (profiled++, result(own, 'a.json')), compare: async () => comparison('neutral'), runChecks: async () => [{ name: 'npm test', ok: true }] });
92
+ assert.equal(noGain.attempt.outcome, 'no-gain');
93
+ assert.equal(s.kept, 0);
94
+ assert.deepEqual(s.current, ['baseline.json'], 'the baseline stays where it was');
95
+ assert.equal(noGain.next.title, 'heavy.js spent 586ms');
96
+ assert.match(checkText(noGain.attempt, noGain.next, { total: 3 }), /^✗ Undo this change: no measurable improvement.*\n {2}Revert the files you changed/);
97
+
98
+ profiled = 0;
99
+ const broken = await checkTask(s, { profile: async () => (profiled++, result(own)), compare: async () => comparison('improved'), runChecks: async () => [{ name: 'npm test', ok: false, tail: 'Error: rows 0' }] });
100
+ assert.equal(broken.attempt.outcome, 'checks-failed');
101
+ assert.equal(profiled, 0, 'a broken page is not measured');
102
+ assert.match(checkText(broken.attempt, broken.next, { total: 3 }), /"npm test" failed[\s\S]*\| Error: rows 0/);
103
+ assert.equal(broken.next.title, 'App rendered 60×');
104
+
105
+ // low confidence measures again once, then gives up
106
+ let n = 0;
107
+ const unproven = await checkTask(s, { profile: async () => result(own, `p${n++}.json`), compare: async () => comparison('improved', 'low'), runChecks: async () => [{ name: 'npm test', ok: true }] });
108
+ assert.equal(n, 2);
109
+ assert.equal(unproven.attempt.outcome, 'unproven');
110
+ assert.equal(unproven.next, null);
111
+ assert.match(checkText(unproven.attempt, null, { total: 3 }), /No more problems to try\. Run: {2}npx spectra improve report/);
112
+ await assert.rejects(checkTask(s, {}), /nothing to check/);
113
+ });
114
+
115
+ test('the report says what happened, and the overall change compares the baseline with the latest kept state', async () => {
116
+ const { s } = await session();
117
+ assert.match(reportText(s, null), /3 problems found in your own code/);
118
+ nextTask(s);
119
+ await checkTask(s, { profile: async () => result(own.slice(1), 'k1.json'), compare: async () => comparison('improved', 'high'), runChecks: async () => [{ name: 'npm test', ok: true }] });
120
+ await checkTask(s, { profile: async () => result(own.slice(1), 'u1.json'), compare: async () => comparison('regressed'), runChecks: async () => [{ name: 'npm test', ok: true }] });
121
+ const seen = [];
122
+ const final = await overall(s, async (before, after) => (seen.push([before, after]), comparison('improved', 'high')));
123
+ assert.deepEqual(seen, [[['baseline.json'], ['k1.json']]]);
124
+ const text = reportText(s, final);
125
+ assert.match(text, /^Spectra improve — 1 of 2 problems fixed/);
126
+ assert.match(text, /✓ fixed .*Row is slow to render \(src\/Row\.jsx:3\): JavaScript CPU time/);
127
+ assert.match(text, /✗ undone: slower .*heavy\.js spent 586ms/);
128
+ assert.match(text, /Overall, before → after: IMPROVED \(confidence: high\)/);
129
+ assert.match(text, /in your working tree, uncommitted/);
130
+ const md = reportMarkdown(s, final);
131
+ assert.match(md, /^## ⚡ Spectra improve: 1 of 2 problems fixed/);
132
+ assert.match(md, /\| Row is slow to render \| `src\/Row\.jsx:3` \| ✅ fixed \|/);
133
+ assert.match(md, /❌ undone: slower/);
134
+ assert.equal(await overall({ kept: 0 }, async () => assert.fail('nothing kept, nothing to compare')), null);
135
+ });