@progen-labs/spectra 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -235,12 +235,13 @@ The harness, not the agent, makes every decision:
235
235
  - **Proof.** The change is kept only if `spectra compare` says *improved* with medium or high confidence and no new
236
236
  critical finding; with low confidence it measures again.
237
237
  - **A record.** Each kept commit carries its evidence; `.spectra/fix/<time>/report.md` summarises the session, and a
238
- history skips findings that failed before.
238
+ history skips findings that failed before. A finding that an earlier kept fix already cleared is skipped too.
239
239
 
240
240
  By default the agent is Claude Code in headless mode, allowed only to read and edit files (no shell, no web, no MCP),
241
241
  with a spending cap per finding. A test bench (`npm run bench`) checks the harness's decisions in CI: a real fix is
242
242
  kept, a "faster" change that breaks the page is caught, a cosmetic change doesn't count, and an edit to
243
- `package.json` is refused. See [the AI fix harness](https://progen-labs.github.io/website/#spectra/docs/harness).
243
+ `package.json` is refused. With Claude Code on the demo app, it kept two of three fixes and cut JavaScript CPU time
244
+ from 1.51 s to 82.5 ms (−95%) for $0.44. See [the AI fix harness](https://progen-labs.github.io/website/#spectra/docs/harness).
244
245
 
245
246
  ## In VS Code
246
247
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@progen-labs/spectra",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Spectra \u2014 a Chrome DevTools Protocol profiler that identifies the files, functions and components causing frontend slowness, across React, Vue, Svelte, Angular and Next.js builds.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,7 @@
4
4
  * Everything it touches is passed in (profile, compare, agent, workspace,
5
5
  * checks), so the loop itself is tested without a browser or a model.
6
6
  */
7
+ import { findingKey } from '../../shared/compare.js';
7
8
  import { fixPrompt, judge, rankFindings, scopeProblem } from './policy.js';
8
9
 
9
10
  // agent errors no other finding would get past: stop instead of trying them all
@@ -34,6 +35,7 @@ export async function runFix(o) {
34
35
  const baseline = await profile('baseline');
35
36
  session.baseline = { file: baseline.file, summary: baseline.result.summary, url: baseline.result.url };
36
37
  let current = [baseline.file];
38
+ let present = null; // finding keys the latest kept change still measures
37
39
 
38
40
  const { candidates, skipped } = rankFindings(baseline.result.findings, { exists: ws.exists, history, retry, max: maxFixes });
39
41
  session.skipped = skipped.map(s => ({ title: s.finding.title, reason: s.reason }));
@@ -46,6 +48,12 @@ export async function runFix(o) {
46
48
  for (const [i, f] of candidates.entries()) {
47
49
  if (signal?.aborted) break;
48
50
  const n = `${i + 1}/${candidates.length}`;
51
+ if (present && !present.has(f.key)) {
52
+ // one fix often clears several findings (a memoised row also stops its hot file)
53
+ session.skipped.push({ title: f.title, reason: 'already resolved by an earlier kept change' });
54
+ log(`[${n}] – ${f.title}: already resolved by an earlier kept change.`);
55
+ continue;
56
+ }
49
57
  const attempt = { finding: { title: f.title, file: f.file, line: f.line, severity: f.severity, impactMs: f.impactMs, key: f.key }, outcome: null, reason: '', changes: [] };
50
58
  session.attempts.push(attempt);
51
59
  const discard = (outcome, reason) => {
@@ -127,6 +135,7 @@ export async function runFix(o) {
127
135
  Object.assign(attempt, { outcome: 'kept', reason: verdict.reason });
128
136
  session.kept++;
129
137
  current = after.map(a => a.file);
138
+ present = new Set(after.flatMap(a => a.result.findings || []).map(findingKey));
130
139
  log(`[${n}] ✓ kept (${attempt.commit}): ${verdict.reason}`);
131
140
  }
132
141
 
@@ -70,7 +70,8 @@ test('tells the agent the finding and the rules', () => {
70
70
  });
71
71
 
72
72
  // --- the loop, with fakes
73
- function fakes({ agentChanges, checks = [true, true, true, true], verdicts }) {
73
+ const bothFindings = () => [finding('row-render'), finding('heavy', { file: 'src/heavy.js', line: 2, culprits: [] })];
74
+ function fakes({ agentChanges, checks = [true, true, true, true], verdicts, measured = bothFindings }) {
74
75
  const calls = { commits: [], undos: 0, profiles: 0, compares: [] };
75
76
  let pending = [];
76
77
  let checkRun = 0;
@@ -91,7 +92,7 @@ function fakes({ agentChanges, checks = [true, true, true, true], verdicts }) {
91
92
  workspace,
92
93
  checks: [{ name: 'npm test' }],
93
94
  runChecks: async () => [{ name: 'npm test', ok: checks[checkRun++] ?? true, tail: 'expected 120 rows' }],
94
- profile: async label => ((calls.profiles++), { file: `${label}.json`, result: { url: 'http://localhost:5199', summary: {}, findings: [finding('row-render'), finding('heavy', { file: 'src/heavy.js', line: 2, culprits: [] })] } }),
95
+ profile: async label => ((calls.profiles++), { file: `${label}.json`, result: { url: 'http://localhost:5199', summary: {}, findings: measured(label) } }),
95
96
  compare: async (before, after) => (calls.compares.push([before, after]), verdicts[verdictRun++] || comparison('neutral')),
96
97
  },
97
98
  };
@@ -112,6 +113,21 @@ test('keeps a change that passes the checks and measures faster, and builds on i
112
113
  assert.match(reportMarkdown({ ...s, runs: 3, branch: 'spectra/fix-x' }), /1 of 2 findings fixed/);
113
114
  });
114
115
 
116
+ test('skips a finding that an earlier kept change already resolved', async () => {
117
+ // the memoised row also stops the hot file: after the first fix, neither is measured
118
+ const measured = label => (label === 'baseline' ? bothFindings() : []);
119
+ const { calls, options } = fakes({ agentChanges: [edit, edit], verdicts: [comparison('improved'), comparison('improved')], measured });
120
+ let asked = 0;
121
+ const run = options.agent.run;
122
+ options.agent.run = (...a) => (asked++, run(...a));
123
+ const s = await runFix(options);
124
+ assert.equal(asked, 1, 'the agent is not asked about the resolved finding');
125
+ assert.deepEqual(s.attempts.map(a => a.outcome), ['kept']);
126
+ assert.deepEqual(s.skipped, [{ title: 'heavy is slow', reason: 'already resolved by an earlier kept change' }]);
127
+ assert.equal(calls.profiles, 2, 'baseline and one measurement');
128
+ assert.match(reportMarkdown({ ...s, runs: 3, branch: 'spectra/fix-x' }), /heavy is slow: already resolved by an earlier kept change/);
129
+ });
130
+
115
131
  test('undoes a change whose checks fail, without measuring it', async () => {
116
132
  const { calls, options } = fakes({ agentChanges: [edit], checks: [true, false], verdicts: [] });
117
133
  options.workspace.exists = f => f === 'src/Row.jsx';