@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.
- package/README.md +114 -1
- package/bin/spectra.js +53 -4
- package/package.json +11 -4
- package/server/fix/agent.js +96 -0
- package/server/fix/checks.js +53 -0
- package/server/fix/cli.js +237 -0
- package/server/fix/harness.js +138 -0
- package/server/fix/policy.js +126 -0
- package/server/fix/report.js +38 -0
- package/server/fix/test-fix.js +211 -0
- package/server/fix/workspace.js +130 -0
- package/server/mcp.js +678 -0
- package/server/test-mcp.js +170 -0
- package/server/test-result.js +1 -44
- package/server/testing/fixtures.js +49 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// The AI fix harness without a browser or a model: its rules, its loop with
|
|
2
|
+
// fake measurements and agents, and its git copy in a throwaway repository.
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import { commandAgent } from './agent.js';
|
|
10
|
+
import { detectChecks } from './checks.js';
|
|
11
|
+
import { runFix } from './harness.js';
|
|
12
|
+
import { fixPrompt, judge, rankFindings, scopeProblem } from './policy.js';
|
|
13
|
+
import { reportMarkdown } from './report.js';
|
|
14
|
+
import { changesIn, commitChange, commitsOn, createWorkspace, ignoreSpectra, removeWorkspace, repoOf, uncommitted, undoChanges } from './workspace.js';
|
|
15
|
+
|
|
16
|
+
const finding = (id, extra = {}) => ({ id, severity: 'critical', title: `${id} is slow`, impactMs: 500, file: 'src/Row.jsx', line: 3, fix: 'memoize it', culprits: [{ fn: 'Row', file: 'src/Row.jsx', line: 3, ms: 500 }], ...extra });
|
|
17
|
+
const comparison = (verdict, confidence = 'medium', extra = {}) => ({
|
|
18
|
+
verdict,
|
|
19
|
+
confidence,
|
|
20
|
+
headlines: verdict === 'improved' ? [{ kind: 'improved', text: 'JavaScript CPU time: 1.55 s → 107 ms (−93%)' }] : verdict === 'neutral' ? [] : [{ kind: 'degraded', text: 'Total blocking time: 0 ms → 300 ms' }],
|
|
21
|
+
findings: { added: [], resolved: [], persisting: [], ...extra },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('ranks own-code findings, most costly first, one per place', () => {
|
|
25
|
+
const findings = [
|
|
26
|
+
finding('row-render', { impactMs: 1441 }),
|
|
27
|
+
finding('row-count', { impactMs: 1440, title: 'Row rendered 7200×' }), // same file and line
|
|
28
|
+
finding('heavy', { file: 'src/heavy.js', line: 2, impactMs: 1425, culprits: [{ fn: 'burn', file: 'src/heavy.js', line: 2 }] }),
|
|
29
|
+
finding('bundle', { file: 'node_modules/react-dom/index.js', impactMs: 600 }),
|
|
30
|
+
finding('note', { severity: 'info', file: 'src/App.jsx' }),
|
|
31
|
+
finding('gone', { file: 'src/Deleted.jsx' }),
|
|
32
|
+
];
|
|
33
|
+
const exists = f => !f.includes('Deleted');
|
|
34
|
+
const { candidates } = rankFindings(findings, { exists, max: 5 });
|
|
35
|
+
assert.deepEqual(candidates.map(c => c.id), ['row-render', 'heavy']);
|
|
36
|
+
const again = rankFindings(findings, { exists, history: { 'row-render': { outcome: 'checks-failed' } } });
|
|
37
|
+
assert.deepEqual(again.candidates.map(c => c.id), ['row-count', 'heavy'], 'a failed finding is skipped, so the next one at that line gets its chance');
|
|
38
|
+
assert.match(again.skipped[0].reason, /checks failed/);
|
|
39
|
+
assert.deepEqual(rankFindings(findings, { exists, history: { 'row-render': { outcome: 'checks-failed' } }, retry: true }).candidates[0].id, 'row-render');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('keeps changes inside the source, small, and without deleting files', () => {
|
|
43
|
+
const src = (p, added = 3, removed = 1, status = 'M') => ({ path: p, status, added, removed });
|
|
44
|
+
assert.equal(scopeProblem([src('src/Row.jsx')]), null);
|
|
45
|
+
assert.match(scopeProblem([src('package.json')]), /may not touch/);
|
|
46
|
+
assert.match(scopeProblem([src('pnpm-lock.yaml')]), /may not touch/);
|
|
47
|
+
assert.match(scopeProblem([src('src/Row.test.jsx')]), /may not touch/);
|
|
48
|
+
assert.match(scopeProblem([src('vite.config.js')]), /may not touch/);
|
|
49
|
+
assert.match(scopeProblem([src('src/Row.jsx', 0, 9, 'D')]), /deleted/);
|
|
50
|
+
assert.match(scopeProblem([src('src/Row.jsx', 200, 10)]), /more than the 150/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('judges a change from the comparison', () => {
|
|
54
|
+
assert.equal(judge(comparison('improved')).decision, 'keep');
|
|
55
|
+
assert.equal(judge(comparison('improved', 'high')).decision, 'keep');
|
|
56
|
+
assert.equal(judge(comparison('improved', 'low')).decision, 'measure-again');
|
|
57
|
+
assert.equal(judge(comparison('neutral')).outcome, 'no-gain');
|
|
58
|
+
assert.equal(judge(comparison('regressed')).outcome, 'slower');
|
|
59
|
+
assert.equal(judge(comparison('mixed')).outcome, 'mixed');
|
|
60
|
+
assert.equal(judge(comparison('improved', 'high', { added: [{ severity: 'critical', title: 'Long task' }] })).outcome, 'new-problem');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('tells the agent the finding and the rules', () => {
|
|
64
|
+
const p = fixPrompt(finding('row'), { url: 'http://localhost:5199' });
|
|
65
|
+
assert.match(p, /row is slow/);
|
|
66
|
+
assert.match(p, /src\/Row\.jsx:3/);
|
|
67
|
+
assert.match(p, /Keep the behaviour exactly the same/);
|
|
68
|
+
assert.match(p, /Do not run commands/);
|
|
69
|
+
assert.match(p, /localhost:5199/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// --- the loop, with fakes
|
|
73
|
+
function fakes({ agentChanges, checks = [true, true, true, true], verdicts }) {
|
|
74
|
+
const calls = { commits: [], undos: 0, profiles: 0, compares: [] };
|
|
75
|
+
let pending = [];
|
|
76
|
+
let checkRun = 0;
|
|
77
|
+
let verdictRun = 0;
|
|
78
|
+
const workspace = {
|
|
79
|
+
projectDir: '/tmp/project',
|
|
80
|
+
exists: () => true,
|
|
81
|
+
changes: () => pending,
|
|
82
|
+
diff: () => 'diff',
|
|
83
|
+
commit: (files, message) => (calls.commits.push({ files, message }), (pending = []), 'abc1234'),
|
|
84
|
+
undo: () => ((pending = []), calls.undos++),
|
|
85
|
+
};
|
|
86
|
+
let agentRun = 0;
|
|
87
|
+
return {
|
|
88
|
+
calls,
|
|
89
|
+
options: {
|
|
90
|
+
agent: { name: 'fake', run: async () => ((pending = agentChanges[agentRun++] || []), { summary: 'memoized the work' }) },
|
|
91
|
+
workspace,
|
|
92
|
+
checks: [{ name: 'npm test' }],
|
|
93
|
+
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
|
+
compare: async (before, after) => (calls.compares.push([before, after]), verdicts[verdictRun++] || comparison('neutral')),
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const edit = [{ path: 'src/Row.jsx', status: 'M', added: 2, removed: 1 }];
|
|
100
|
+
|
|
101
|
+
test('keeps a change that passes the checks and measures faster, and builds on it', async () => {
|
|
102
|
+
const { calls, options } = fakes({ agentChanges: [edit, edit], verdicts: [comparison('improved'), comparison('neutral'), comparison('improved')] });
|
|
103
|
+
const s = await runFix(options);
|
|
104
|
+
assert.deepEqual(s.attempts.map(a => a.outcome), ['kept', 'no-gain']);
|
|
105
|
+
assert.equal(s.kept, 1);
|
|
106
|
+
assert.equal(calls.commits.length, 1);
|
|
107
|
+
assert.match(calls.commits[0].message, /^perf: row-render is slow/);
|
|
108
|
+
assert.match(calls.commits[0].message, /Verified by spectra fix: improved \(confidence: medium\)/);
|
|
109
|
+
assert.deepEqual(calls.compares[1], [['after 1.json'], ['after 2.json']], 'the second fix is measured against the first');
|
|
110
|
+
assert.deepEqual(calls.compares[2], [['baseline.json'], ['after 1.json']], 'the overall comparison starts from the baseline');
|
|
111
|
+
assert.equal(s.final.verdict, 'improved');
|
|
112
|
+
assert.match(reportMarkdown({ ...s, runs: 3, branch: 'spectra/fix-x' }), /1 of 2 findings fixed/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('undoes a change whose checks fail, without measuring it', async () => {
|
|
116
|
+
const { calls, options } = fakes({ agentChanges: [edit], checks: [true, false], verdicts: [] });
|
|
117
|
+
options.workspace.exists = f => f === 'src/Row.jsx';
|
|
118
|
+
const s = await runFix(options);
|
|
119
|
+
assert.equal(s.attempts[0].outcome, 'checks-failed');
|
|
120
|
+
assert.equal(calls.undos, 1);
|
|
121
|
+
assert.equal(calls.profiles, 1, 'only the baseline was measured');
|
|
122
|
+
assert.equal(calls.commits.length, 0);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('refuses to start when a check fails before any change', async () => {
|
|
126
|
+
const { options } = fakes({ agentChanges: [], checks: [false], verdicts: [] });
|
|
127
|
+
await assert.rejects(runFix(options), /fails before any change/);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('undoes out-of-scope and empty changes', async () => {
|
|
131
|
+
const { calls, options } = fakes({ agentChanges: [[{ path: 'package.json', status: 'M', added: 1, removed: 1 }], []], verdicts: [] });
|
|
132
|
+
const s = await runFix(options);
|
|
133
|
+
assert.deepEqual(s.attempts.map(a => a.outcome), ['out-of-scope', 'no-change']);
|
|
134
|
+
assert.equal(calls.undos, 1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('stops at once when the agent cannot work at all', async () => {
|
|
138
|
+
const { options } = fakes({ agentChanges: [], verdicts: [] });
|
|
139
|
+
let runs = 0;
|
|
140
|
+
options.agent = { name: 'Claude Code', run: async () => (runs++, { summary: '', error: 'Not logged in · Please run /login' }) };
|
|
141
|
+
await assert.rejects(runFix(options), /Claude Code cannot work: Not logged in/);
|
|
142
|
+
assert.equal(runs, 1, 'no other finding was tried');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('measures again when the gain has low confidence', async () => {
|
|
146
|
+
const kept = fakes({ agentChanges: [edit], verdicts: [comparison('improved', 'low'), comparison('improved', 'medium'), comparison('improved')] });
|
|
147
|
+
kept.options.workspace.exists = f => f === 'src/Row.jsx';
|
|
148
|
+
const s = await runFix(kept.options);
|
|
149
|
+
assert.equal(s.attempts[0].outcome, 'kept');
|
|
150
|
+
assert.deepEqual(kept.calls.compares[1], [['baseline.json'], ['after 1.json', 'after 1, more runs.json']]);
|
|
151
|
+
const unproven = fakes({ agentChanges: [edit], verdicts: [comparison('improved', 'low'), comparison('improved', 'low')] });
|
|
152
|
+
unproven.options.workspace.exists = f => f === 'src/Row.jsx';
|
|
153
|
+
assert.equal((await runFix(unproven.options)).attempts[0].outcome, 'unproven');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// --- the git copy, in a throwaway repository
|
|
157
|
+
const sh = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim();
|
|
158
|
+
function repo() {
|
|
159
|
+
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-fix-test-')));
|
|
160
|
+
sh(dir, 'init', '-q', '-b', 'main');
|
|
161
|
+
fs.mkdirSync(path.join(dir, 'app/src'), { recursive: true });
|
|
162
|
+
fs.mkdirSync(path.join(dir, 'app/node_modules/dep'), { recursive: true });
|
|
163
|
+
fs.writeFileSync(path.join(dir, 'app/src/Row.jsx'), 'export const Row = () => null;\n');
|
|
164
|
+
fs.writeFileSync(path.join(dir, 'app/package.json'), JSON.stringify({ scripts: { test: 'echo "Error: no test specified" && exit 1', build: 'vite build', typecheck: 'tsc' } }));
|
|
165
|
+
fs.writeFileSync(path.join(dir, '.gitignore'), 'node_modules\n');
|
|
166
|
+
sh(dir, 'add', '-A');
|
|
167
|
+
sh(dir, '-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'init');
|
|
168
|
+
return dir;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
test('works in a git copy: links dependencies, commits kept changes, undoes the rest', () => {
|
|
172
|
+
const root = repo();
|
|
173
|
+
const { root: r, sub } = repoOf(path.join(root, 'app'));
|
|
174
|
+
assert.equal(r, root);
|
|
175
|
+
assert.equal(sub, 'app');
|
|
176
|
+
ignoreSpectra(root);
|
|
177
|
+
fs.mkdirSync(path.join(root, 'app/.spectra/fix'), { recursive: true });
|
|
178
|
+
assert.deepEqual(uncommitted(root), [], '.spectra is ignored');
|
|
179
|
+
|
|
180
|
+
const ws = createWorkspace({ root, sub, dir: path.join(root, 'app/.spectra/fix/s1/worktree'), branch: 'spectra/fix-test' });
|
|
181
|
+
assert.ok(fs.lstatSync(path.join(ws.projectDir, 'node_modules')).isSymbolicLink());
|
|
182
|
+
|
|
183
|
+
fs.writeFileSync(path.join(ws.projectDir, 'src/Row.jsx'), 'export const Row = () => null; // faster\n');
|
|
184
|
+
fs.writeFileSync(path.join(ws.projectDir, 'src/cache.js'), 'export const cache = new Map();\n');
|
|
185
|
+
const changes = changesIn(ws);
|
|
186
|
+
assert.deepEqual(changes.map(c => [c.path, c.status]).sort(), [['src/Row.jsx', 'M'], ['src/cache.js', 'A']]);
|
|
187
|
+
commitChange(ws, changes.map(c => c.path), 'perf: test');
|
|
188
|
+
assert.equal(commitsOn(root, 'spectra/fix-test'), 1);
|
|
189
|
+
|
|
190
|
+
fs.writeFileSync(path.join(ws.projectDir, 'src/Row.jsx'), 'broken');
|
|
191
|
+
fs.writeFileSync(path.join(ws.projectDir, 'src/junk.js'), 'junk');
|
|
192
|
+
undoChanges(ws);
|
|
193
|
+
assert.deepEqual(changesIn(ws), []);
|
|
194
|
+
assert.ok(fs.existsSync(path.join(ws.projectDir, 'node_modules')), 'the dependency link survives an undo');
|
|
195
|
+
assert.equal(fs.readFileSync(path.join(root, 'app/src/Row.jsx'), 'utf8'), 'export const Row = () => null;\n', 'the user\'s files never change');
|
|
196
|
+
|
|
197
|
+
removeWorkspace(root, ws, { keepBranch: true });
|
|
198
|
+
assert.ok(!fs.existsSync(ws.dir));
|
|
199
|
+
assert.equal(commitsOn(root, 'spectra/fix-test'), 1, 'the branch with the fix stays');
|
|
200
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('finds checks in package.json and runs any agent command', async () => {
|
|
204
|
+
const root = repo();
|
|
205
|
+
assert.deepEqual(detectChecks(path.join(root, 'app')).map(c => c.cmd), ['npm run typecheck', 'npm run build'], 'the npm placeholder test is not a check');
|
|
206
|
+
const agent = commandAgent(`node -e "const fs=require('fs');const p=fs.readFileSync(process.env.SPECTRA_FIX_PROMPT_FILE,'utf8');fs.writeFileSync('src/Row.jsx','// '+p.split('\\n')[2]+'\\n');console.log('rewrote Row')"`);
|
|
207
|
+
const res = await agent.run('Fix it.\n\nFinding: Row is slow', { cwd: path.join(root, 'app'), finding: {} });
|
|
208
|
+
assert.equal(res.summary, 'rewrote Row');
|
|
209
|
+
assert.equal(fs.readFileSync(path.join(root, 'app/src/Row.jsx'), 'utf8'), '// Finding: Row is slow\n');
|
|
210
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
211
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolated copy `spectra fix` works in: a git worktree of the last commit
|
|
3
|
+
* on its own branch, inside .spectra/fix/<session>/ (which git is told to
|
|
4
|
+
* ignore). Every attempt is either committed on that branch or undone, so a
|
|
5
|
+
* failed attempt never reaches the user's working tree.
|
|
6
|
+
*/
|
|
7
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const gitRaw = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
12
|
+
const git = (cwd, args) => gitRaw(cwd, args).trim();
|
|
13
|
+
const tryGit = (cwd, args) => {
|
|
14
|
+
try {
|
|
15
|
+
return git(cwd, args);
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The repository around `dir`, and where the project sits inside it. */
|
|
22
|
+
export function repoOf(dir) {
|
|
23
|
+
const root = tryGit(dir, ['rev-parse', '--show-toplevel']);
|
|
24
|
+
if (!root) throw new Error('spectra fix needs a git repository: it works on a copy of your last commit.');
|
|
25
|
+
const real = fs.realpathSync(dir);
|
|
26
|
+
return { root: fs.realpathSync(root), sub: path.relative(fs.realpathSync(root), real) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Uncommitted changes in the project (other than Spectra's own files). */
|
|
30
|
+
export function uncommitted(root) {
|
|
31
|
+
return gitRaw(root, ['status', '--porcelain', '--untracked-files=normal'])
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.filter(l => !/(^|\/)\.spectra(\/|$)/.test(l.slice(3)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Tell git, locally, to ignore .spectra/ so the session never shows up as a change. */
|
|
38
|
+
export function ignoreSpectra(root) {
|
|
39
|
+
const file = path.resolve(root, git(root, ['rev-parse', '--git-path', 'info/exclude']));
|
|
40
|
+
const now = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
41
|
+
if (!now.split('\n').some(l => l.trim() === '.spectra/' || l.trim() === '.spectra')) {
|
|
42
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
|
+
fs.appendFileSync(file, `${now && !now.endsWith('\n') ? '\n' : ''}.spectra/\n`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A worktree of HEAD on a new branch, with the project's dependencies linked
|
|
49
|
+
* in (or installed fresh, with install).
|
|
50
|
+
*/
|
|
51
|
+
export function createWorkspace({ root, sub, dir, branch, install = false, log = () => {} }) {
|
|
52
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
53
|
+
git(root, ['worktree', 'add', '-q', '-b', branch, dir, 'HEAD']);
|
|
54
|
+
const projectDir = path.join(dir, sub);
|
|
55
|
+
if (install) {
|
|
56
|
+
const lock = f => fs.existsSync(path.join(projectDir, f));
|
|
57
|
+
const [cmd, ...args] = lock('pnpm-lock.yaml') ? ['pnpm', 'install', '--frozen-lockfile'] : lock('yarn.lock') ? ['yarn', 'install', '--frozen-lockfile'] : lock('package-lock.json') ? ['npm', 'ci'] : ['npm', 'install'];
|
|
58
|
+
log(`installing dependencies in the copy (${cmd} ${args.join(' ')}) …`);
|
|
59
|
+
const r = spawnSync(cmd, args, { cwd: projectDir, stdio: 'ignore', shell: process.platform === 'win32' });
|
|
60
|
+
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} failed in the copy.`);
|
|
61
|
+
} else {
|
|
62
|
+
// node_modules of the project and of the repository root (hoisted workspaces)
|
|
63
|
+
for (const rel of new Set([sub, ''])) {
|
|
64
|
+
const from = path.join(root, rel, 'node_modules');
|
|
65
|
+
const to = path.join(dir, rel, 'node_modules');
|
|
66
|
+
if (fs.existsSync(from) && !fs.existsSync(to)) fs.symlinkSync(from, to, process.platform === 'win32' ? 'junction' : 'dir');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { dir, projectDir, branch };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the agent changed: [{ path, status, added, removed }], paths relative to the project. */
|
|
73
|
+
export function changesIn(ws) {
|
|
74
|
+
const status = gitRaw(ws.projectDir, ['status', '--porcelain', '--untracked-files=all', '--', '.'])
|
|
75
|
+
.split('\n')
|
|
76
|
+
.filter(Boolean)
|
|
77
|
+
.map(l => ({ status: l.slice(0, 2).trim()[0] === '?' ? 'A' : l.slice(0, 2).trim()[0], file: l.slice(3).replace(/^"|"$/g, '') }))
|
|
78
|
+
.filter(c => !/(^|\/)node_modules$/.test(c.file));
|
|
79
|
+
const counts = new Map(
|
|
80
|
+
git(ws.projectDir, ['diff', '--numstat', 'HEAD', '--', '.'])
|
|
81
|
+
.split('\n')
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map(l => {
|
|
84
|
+
const [a, r, f] = l.split('\t');
|
|
85
|
+
return [f, { added: Number(a) || 0, removed: Number(r) || 0 }];
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
const prefix = git(ws.projectDir, ['rev-parse', '--show-prefix']);
|
|
89
|
+
return status.map(c => {
|
|
90
|
+
const rel = c.file.startsWith(prefix) ? c.file.slice(prefix.length) : c.file;
|
|
91
|
+
const n = counts.get(c.file) || (c.status === 'A' ? { added: lineCount(path.join(ws.dir, c.file)), removed: 0 } : { added: 0, removed: 0 });
|
|
92
|
+
return { path: rel, status: c.status, ...n };
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
const lineCount = file => {
|
|
96
|
+
try {
|
|
97
|
+
return fs.readFileSync(file, 'utf8').split('\n').length;
|
|
98
|
+
} catch {
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** The diff of the files a change touched, for the report. */
|
|
104
|
+
export const diffOf = ws => git(ws.projectDir, ['diff', 'HEAD', '--', '.']);
|
|
105
|
+
|
|
106
|
+
/** Commit exactly these files on the session branch. */
|
|
107
|
+
export function commitChange(ws, files, message) {
|
|
108
|
+
git(ws.projectDir, ['add', '--', ...files]);
|
|
109
|
+
const named = tryGit(ws.dir, ['config', 'user.email']);
|
|
110
|
+
const who = named ? [] : ['-c', 'user.name=Spectra fix', '-c', 'user.email=spectra-fix@localhost'];
|
|
111
|
+
git(ws.dir, [...who, 'commit', '-q', '-m', message]);
|
|
112
|
+
undoChanges(ws); // anything the checks left behind
|
|
113
|
+
return git(ws.dir, ['rev-parse', '--short', 'HEAD']);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Undo everything since the last commit (the dependency link stays). */
|
|
117
|
+
export function undoChanges(ws) {
|
|
118
|
+
git(ws.dir, ['reset', '-q', '--hard']);
|
|
119
|
+
git(ws.dir, ['clean', '-fdq', '-e', 'node_modules']);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Remove the copy; keep the branch only if it holds fixes. */
|
|
123
|
+
export function removeWorkspace(root, ws, { keepBranch }) {
|
|
124
|
+
tryGit(root, ['worktree', 'remove', '--force', ws.dir]);
|
|
125
|
+
if (fs.existsSync(ws.dir)) fs.rmSync(ws.dir, { recursive: true, force: true });
|
|
126
|
+
tryGit(root, ['worktree', 'prune']);
|
|
127
|
+
if (!keepBranch) tryGit(root, ['branch', '-D', ws.branch]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const commitsOn = (root, branch) => Number(tryGit(root, ['rev-list', '--count', `HEAD..${branch}`]) || 0);
|