@progen-labs/spectra 0.2.0 → 0.4.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,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,138 @@
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 { fixPrompt, judge, rankFindings, scopeProblem } from './policy.js';
8
+
9
+ // agent errors no other finding would get past: stop instead of trying them all
10
+ const FATAL = /not logged in|\/login|authenticat|api key|credit balance|was not found|could not start/i;
11
+
12
+ /**
13
+ * @param {object} o
14
+ * @param {(label: string) => Promise<{file: string, result: object}>} o.profile
15
+ * @param {(before: string[], after: string[]) => Promise<object>} o.compare spectra compare's comparison
16
+ * @param {{name: string, run: Function}} o.agent
17
+ * @param {object} o.workspace { projectDir, exists(file), changes(), diff(), commit(files, message), undo() }
18
+ * @param {() => Promise<{name: string, ok: boolean}[]>} o.runChecks
19
+ * @param {{name: string}[]} o.checks
20
+ * @returns {Promise<object>} the session: baseline, attempts, overall comparison
21
+ */
22
+ export async function runFix(o) {
23
+ const { profile, compare, agent, workspace: ws, runChecks, checks = [], log = () => {}, maxFixes = 3, maxLines = 150, history = {}, retry = false, url, signal } = o;
24
+ const session = { agent: agent.name, checks: checks.map(c => c.name), attempts: [], skipped: [], baseline: null, final: null, kept: 0 };
25
+
26
+ if (checks.length) {
27
+ log(`running the checks before any change (${checks.map(c => c.name).join(', ')}) …`);
28
+ const before = await runChecks();
29
+ const failed = before.find(r => !r.ok);
30
+ 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 });
31
+ }
32
+
33
+ log('measuring the baseline …');
34
+ const baseline = await profile('baseline');
35
+ session.baseline = { file: baseline.file, summary: baseline.result.summary, url: baseline.result.url };
36
+ let current = [baseline.file];
37
+
38
+ const { candidates, skipped } = rankFindings(baseline.result.findings, { exists: ws.exists, history, retry, max: maxFixes });
39
+ session.skipped = skipped.map(s => ({ title: s.finding.title, reason: s.reason }));
40
+ if (!candidates.length) {
41
+ log('no findings in your own code to fix.');
42
+ return session;
43
+ }
44
+ log(`${candidates.length} finding${candidates.length === 1 ? '' : 's'} to attempt, most costly first.`);
45
+
46
+ for (const [i, f] of candidates.entries()) {
47
+ if (signal?.aborted) break;
48
+ const n = `${i + 1}/${candidates.length}`;
49
+ 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: [] };
50
+ session.attempts.push(attempt);
51
+ const discard = (outcome, reason) => {
52
+ ws.undo();
53
+ Object.assign(attempt, { outcome, reason });
54
+ log(`[${n}] ✗ ${reason} — change undone.`);
55
+ };
56
+
57
+ log(`[${n}] ${f.title} — ${f.file}${f.line ? `:${f.line}` : ''}`);
58
+ log(`[${n}] asking ${agent.name} for one focused change …`);
59
+ const t0 = Date.now();
60
+ try {
61
+ const res = await agent.run(fixPrompt(f, { url }), { cwd: ws.projectDir, signal, finding: attempt.finding });
62
+ Object.assign(attempt, { summary: res.summary, costUsd: res.costUsd, agentMs: Date.now() - t0 });
63
+ if (res.error) throw new Error(res.error);
64
+ } catch (err) {
65
+ discard('agent-error', `the agent failed: ${err.message}`);
66
+ if (FATAL.test(err.message)) throw Object.assign(new Error(`${agent.name} cannot work: ${err.message}`), { session });
67
+ continue;
68
+ }
69
+
70
+ const changes = ws.changes();
71
+ attempt.changes = changes;
72
+ if (!changes.length) {
73
+ Object.assign(attempt, { outcome: 'no-change', reason: 'the agent made no change' });
74
+ log(`[${n}] – the agent made no change.`);
75
+ continue;
76
+ }
77
+ attempt.diff = ws.diff();
78
+ log(`[${n}] changed ${changes.map(c => `${c.path} (+${c.added} −${c.removed})`).join(', ')}${attempt.summary ? `: ${attempt.summary}` : ''}`);
79
+ const problem = scopeProblem(changes, { maxLines });
80
+ if (problem) {
81
+ discard('out-of-scope', problem);
82
+ continue;
83
+ }
84
+
85
+ if (checks.length) {
86
+ log(`[${n}] running the checks …`);
87
+ const results = await runChecks();
88
+ attempt.checks = results.map(r => ({ name: r.name, ok: r.ok }));
89
+ const failed = results.find(r => !r.ok);
90
+ if (failed) {
91
+ attempt.checkTail = failed.tail;
92
+ discard('checks-failed', `"${failed.name}" failed`);
93
+ continue;
94
+ }
95
+ }
96
+
97
+ log(`[${n}] measuring the change …`);
98
+ const after = [await profile(`after ${i + 1}`)];
99
+ let comparison = await compare(current, after.map(a => a.file));
100
+ let verdict = judge(comparison);
101
+ if (verdict.decision === 'measure-again') {
102
+ log(`[${n}] improved with low confidence; measuring again …`);
103
+ after.push(await profile(`after ${i + 1}, more runs`));
104
+ comparison = await compare(current, after.map(a => a.file));
105
+ verdict = judge(comparison);
106
+ if (verdict.decision === 'measure-again') verdict = { decision: 'discard', outcome: 'unproven', reason: 'not proven: still low confidence after more runs' };
107
+ }
108
+ 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) };
109
+ if (verdict.decision !== 'keep') {
110
+ discard(verdict.outcome, verdict.reason);
111
+ continue;
112
+ }
113
+
114
+ const message = [
115
+ `perf: ${f.title.replace(/\s+/g, ' ').slice(0, 72)}`,
116
+ '',
117
+ ...(attempt.summary ? [attempt.summary, ''] : []),
118
+ `Verified by spectra fix: ${comparison.verdict} (confidence: ${comparison.confidence}).`,
119
+ ...verdict.headlines.map(h => `- ${h}`),
120
+ ...(attempt.checks?.length ? [`Checks passed: ${attempt.checks.map(c => c.name).join(', ')}.`] : []),
121
+ `Finding: ${f.file}${f.line ? `:${f.line}` : ''}`,
122
+ ].join('\n');
123
+ attempt.commit = ws.commit(
124
+ changes.map(c => c.path),
125
+ message,
126
+ );
127
+ Object.assign(attempt, { outcome: 'kept', reason: verdict.reason });
128
+ session.kept++;
129
+ current = after.map(a => a.file);
130
+ log(`[${n}] ✓ kept (${attempt.commit}): ${verdict.reason}`);
131
+ }
132
+
133
+ if (session.kept) {
134
+ const overall = await compare([baseline.file], current);
135
+ session.final = { verdict: overall.verdict, confidence: overall.confidence, headlines: overall.headlines, resolved: overall.findings.resolved.map(x => x.title) };
136
+ }
137
+ return session;
138
+ }
@@ -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
+ }