@delegance/claude-autopilot 1.0.0-alpha.5 → 1.0.0-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/package.json +5 -2
- package/scripts/autoregress.ts +385 -0
- package/src/cli/hook.ts +79 -0
- package/src/cli/index.ts +9 -0
- package/src/snapshots/impact-selector.ts +60 -0
- package/src/snapshots/import-scanner.ts +44 -0
- package/src/snapshots/serializer.ts +24 -0
- package/tests/snapshots/baselines/.gitkeep +0 -0
- package/tests/snapshots/baselines/src-formatters-sarif.json +210 -0
- package/tests/snapshots/baselines/src-snapshots-impact-selector.json +32 -0
- package/tests/snapshots/baselines/src-snapshots-import-scanner.json +21 -0
- package/tests/snapshots/baselines/src-snapshots-serializer.json +39 -0
- package/tests/snapshots/import-map.json +138 -0
- package/tests/snapshots/index.json +14 -0
- package/tests/snapshots/src-formatters-sarif.snap.ts +132 -0
- package/tests/snapshots/src-snapshots-impact-selector.snap.ts +95 -0
- package/tests/snapshots/src-snapshots-import-scanner.snap.ts +126 -0
- package/tests/snapshots/src-snapshots-serializer.snap.ts +64 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0-alpha.7
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`autopilot hook install`** — writes a `pre-push` git hook that runs `autoregress run` before every push; `hook uninstall` removes it; `hook status` shows current state; `--force` overwrites existing hook
|
|
8
|
+
- **`autoregress diff`** — colored snapshot viewer showing line-by-line JSON diffs between current output and baselines; exits 1 if any diffs found (never modifies baselines — use `update` for that)
|
|
9
|
+
- **`autoregress generate --files <list>`** — explicit comma-separated file list bypasses git detection; generates baselines for any src file on demand
|
|
10
|
+
- **Real baselines** — `tests/snapshots/*.snap.ts` + baselines for `serializer.ts`, `import-scanner.ts`, `impact-selector.ts`, and `sarif.ts` — alpha.6 infrastructure now self-testing via snapshots
|
|
11
|
+
|
|
12
|
+
## 1.0.0-alpha.6
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **Auto-regression testing** (`scripts/autoregress.ts generate|run|update`) — autoresearch-inspired snapshot tests for changed source modules
|
|
17
|
+
- **Impact-aware selection** — only fires snapshots whose source modules (or one-hop importers) were touched; high-impact paths (`src/core/pipeline/**`, `src/adapters/**`, `src/core/findings/**`, `src/core/config/**`) and >10-file changes trigger full run
|
|
18
|
+
- **Snapshot serializer** (`src/snapshots/serializer.ts`) — deterministic JSON normalization: sorted keys, `<timestamp>`, `<uuid>`, path stripping
|
|
19
|
+
- **Import scanner** (`src/snapshots/import-scanner.ts`) — static `import`/`export` graph → reverse dependency map
|
|
20
|
+
- **Impact selector** (`src/snapshots/impact-selector.ts`) — merge-base diff + one-hop expansion + overrides
|
|
21
|
+
- **Baseline capture** — `CAPTURE_BASELINE=1` env flag; `autoregress update` rewrites baselines after intentional changes
|
|
22
|
+
- **Staleness detection** — warns and skips snapshots whose `@snapshot-for` source file no longer exists
|
|
23
|
+
- 10 new unit tests (AR1-AR10) for serializer, import scanner, and impact selector
|
|
24
|
+
|
|
3
25
|
## 1.0.0-alpha.5 (2026-04-21)
|
|
4
26
|
|
|
5
27
|
### New Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delegance/claude-autopilot",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Claude Code automation pipeline: spec → plan → implement → validate → PR",
|
|
6
6
|
"keywords": ["claude", "autopilot", "ai", "pipeline", "code-review", "cli"],
|
|
@@ -20,12 +20,15 @@
|
|
|
20
20
|
"src/",
|
|
21
21
|
"presets/",
|
|
22
22
|
"scripts/test-runner.mjs",
|
|
23
|
+
"scripts/autoregress.ts",
|
|
24
|
+
"tests/snapshots/",
|
|
23
25
|
"CHANGELOG.md"
|
|
24
26
|
],
|
|
25
27
|
"scripts": {
|
|
26
28
|
"test": "node scripts/test-runner.mjs",
|
|
27
29
|
"typecheck": "tsc --noEmit",
|
|
28
|
-
"build": "tsc"
|
|
30
|
+
"build": "tsc",
|
|
31
|
+
"autoregress": "tsx scripts/autoregress.ts"
|
|
29
32
|
},
|
|
30
33
|
"devDependencies": {
|
|
31
34
|
"@types/js-yaml": "^4",
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/autoregress.ts
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import * as os from 'node:os';
|
|
6
|
+
import { execSync, spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { selectSnapshots } from '../src/snapshots/impact-selector.ts';
|
|
9
|
+
import OpenAI from 'openai';
|
|
10
|
+
import { buildImportMap } from '../src/snapshots/import-scanner.ts';
|
|
11
|
+
|
|
12
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
14
|
+
const SNAPSHOTS_DIR = path.join(ROOT, 'tests', 'snapshots');
|
|
15
|
+
const INDEX_PATH = path.join(SNAPSHOTS_DIR, 'index.json');
|
|
16
|
+
const IMPORT_MAP_PATH = path.join(SNAPSHOTS_DIR, 'import-map.json');
|
|
17
|
+
const BASELINES_DIR = path.join(SNAPSHOTS_DIR, 'baselines');
|
|
18
|
+
|
|
19
|
+
function loadJson<T>(p: string, fallback: T): T {
|
|
20
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')) as T; } catch { return fallback; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function diffBaselines(baselineJson: string, currentJson: string): string[] {
|
|
24
|
+
if (baselineJson === currentJson) return [];
|
|
25
|
+
const baselineLines = baselineJson.split('\n');
|
|
26
|
+
const currentLines = currentJson.split('\n');
|
|
27
|
+
const lines: string[] = [];
|
|
28
|
+
const maxLen = Math.max(baselineLines.length, currentLines.length);
|
|
29
|
+
for (let i = 0; i < maxLen; i++) {
|
|
30
|
+
const bLine = baselineLines[i];
|
|
31
|
+
const cLine = currentLines[i];
|
|
32
|
+
if (bLine === cLine) continue;
|
|
33
|
+
if (bLine !== undefined) lines.push(`- ${bLine}`);
|
|
34
|
+
if (cLine !== undefined) lines.push(`+ ${cLine}`);
|
|
35
|
+
}
|
|
36
|
+
return lines;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getChangedFiles(since?: string): string[] | null {
|
|
40
|
+
try {
|
|
41
|
+
const base = since
|
|
42
|
+
? since
|
|
43
|
+
: execSync('git merge-base origin/main HEAD', { cwd: ROOT }).toString().trim();
|
|
44
|
+
const out = execSync(`git diff ${base} HEAD --name-only`, { cwd: ROOT }).toString();
|
|
45
|
+
return out.trim().split('\n').filter(Boolean);
|
|
46
|
+
} catch { return null; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function allSnapFiles(): string[] {
|
|
50
|
+
if (!fs.existsSync(SNAPSHOTS_DIR)) return [];
|
|
51
|
+
return fs.readdirSync(SNAPSHOTS_DIR)
|
|
52
|
+
.filter(f => f.endsWith('.snap.ts'))
|
|
53
|
+
.map(f => path.join('tests', 'snapshots', f));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function runSnapshot(snapFile: string, capture: boolean): 'pass' | 'fail' | 'baseline-missing' | 'stale' {
|
|
57
|
+
const absSnap = path.join(ROOT, snapFile);
|
|
58
|
+
const content = fs.readFileSync(absSnap, 'utf8');
|
|
59
|
+
const forMatch = content.match(/@snapshot-for:\s*(.+)/);
|
|
60
|
+
if (forMatch) {
|
|
61
|
+
const src = forMatch[1]!.trim();
|
|
62
|
+
if (!fs.existsSync(path.join(ROOT, src))) {
|
|
63
|
+
console.warn(` [warn] stale — source gone: ${src}`);
|
|
64
|
+
return 'stale';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const slug = path.basename(snapFile, '.snap.ts');
|
|
69
|
+
const baselinePath = path.join(BASELINES_DIR, `${slug}.json`);
|
|
70
|
+
if (!capture && !fs.existsSync(baselinePath)) {
|
|
71
|
+
console.error(` [fail] baseline missing: ${baselinePath}`);
|
|
72
|
+
return 'baseline-missing';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const env = { ...process.env };
|
|
76
|
+
if (capture) env.CAPTURE_BASELINE = '1';
|
|
77
|
+
else delete env.CAPTURE_BASELINE;
|
|
78
|
+
|
|
79
|
+
const result = spawnSync('node', ['--test', '--import', 'tsx', absSnap], {
|
|
80
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
81
|
+
cwd: ROOT,
|
|
82
|
+
env,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (result.status === 0) return 'pass';
|
|
86
|
+
if (capture) return 'pass';
|
|
87
|
+
console.error(` ${(result.stderr?.toString() ?? '') || (result.stdout?.toString() ?? '')}`);
|
|
88
|
+
return 'fail';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cmdRun(args: string[]): number {
|
|
92
|
+
const runAll = args.includes('--all');
|
|
93
|
+
const sinceIdx = args.indexOf('--since');
|
|
94
|
+
const since = sinceIdx >= 0 ? args[sinceIdx + 1] : undefined;
|
|
95
|
+
const index = loadJson<Record<string, string[]>>(INDEX_PATH, {});
|
|
96
|
+
const importMap = loadJson<Record<string, string[]>>(IMPORT_MAP_PATH, {});
|
|
97
|
+
const snapFiles = allSnapFiles();
|
|
98
|
+
|
|
99
|
+
let selected: string[];
|
|
100
|
+
if (runAll || snapFiles.length === 0) {
|
|
101
|
+
selected = snapFiles;
|
|
102
|
+
console.log(`[autoregress run] --all: running ${snapFiles.length} snapshot(s)`);
|
|
103
|
+
} else {
|
|
104
|
+
const changed = getChangedFiles(since);
|
|
105
|
+
if (!changed) {
|
|
106
|
+
console.warn('[autoregress run] merge-base resolution failed — running all');
|
|
107
|
+
selected = snapFiles;
|
|
108
|
+
} else {
|
|
109
|
+
const r = selectSnapshots(changed, snapFiles, index, importMap);
|
|
110
|
+
selected = r.selected;
|
|
111
|
+
console.log(`[autoregress run] ${r.reason} (${selected.length}/${snapFiles.length})`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (selected.length === 0) {
|
|
116
|
+
console.log('[autoregress run] no snapshots to run — pass');
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let passed = 0, failed = 0, missing = 0, stale = 0;
|
|
121
|
+
for (const snap of selected) {
|
|
122
|
+
process.stdout.write(` ${snap} ... `);
|
|
123
|
+
const v = runSnapshot(snap, false);
|
|
124
|
+
if (v === 'pass') { passed++; console.log('pass'); }
|
|
125
|
+
else if (v === 'fail') { failed++; console.log('FAIL'); }
|
|
126
|
+
else if (v === 'baseline-missing') { missing++; console.log('BASELINE MISSING'); }
|
|
127
|
+
else { stale++; console.log('stale (skipped)'); }
|
|
128
|
+
}
|
|
129
|
+
console.log(`\n ${passed} passed ${failed} failed ${missing} baseline-missing ${stale} stale`);
|
|
130
|
+
return failed > 0 || missing > 0 ? 1 : 0;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function cmdUpdate(args: string[]): number {
|
|
134
|
+
const snapIdx = args.indexOf('--snapshot');
|
|
135
|
+
const slug = snapIdx >= 0 ? args[snapIdx + 1] : undefined;
|
|
136
|
+
const snapFiles = slug
|
|
137
|
+
? [path.join('tests', 'snapshots', `${slug}.snap.ts`)]
|
|
138
|
+
: allSnapFiles();
|
|
139
|
+
console.log(`[autoregress update] rewriting ${snapFiles.length} baseline(s)`);
|
|
140
|
+
let failed = 0;
|
|
141
|
+
for (const snap of snapFiles) {
|
|
142
|
+
const absSnap = path.join(ROOT, snap);
|
|
143
|
+
if (!fs.existsSync(absSnap)) {
|
|
144
|
+
console.error(` [error] snapshot file not found: ${snap}`);
|
|
145
|
+
failed++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
process.stdout.write(` ${snap} ... `);
|
|
149
|
+
runSnapshot(snap, true);
|
|
150
|
+
console.log('updated');
|
|
151
|
+
}
|
|
152
|
+
return failed > 0 ? 1 : 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const GENERATOR_VERSION = '1.0.0-alpha.6';
|
|
156
|
+
|
|
157
|
+
const GENERATE_PROMPT = `You are generating a behavioral snapshot test for a TypeScript module.
|
|
158
|
+
|
|
159
|
+
Module path: {filePath}
|
|
160
|
+
Module contents:
|
|
161
|
+
{fileContents}
|
|
162
|
+
|
|
163
|
+
Write a snapshot test file. Requirements:
|
|
164
|
+
1. Header comments at top:
|
|
165
|
+
// @snapshot-for: {filePath}
|
|
166
|
+
// @generated-at: {generatedAt}
|
|
167
|
+
// @source-commit: {sourceCommit}
|
|
168
|
+
// @generator-version: {version}
|
|
169
|
+
2. Import the module's exported functions under test
|
|
170
|
+
3. Import { normalizeSnapshot } from '../../src/snapshots/serializer.ts'
|
|
171
|
+
4. Import fs from 'node:fs', describe/it from 'node:test', assert from 'node:assert/strict'
|
|
172
|
+
5. Baseline loading pattern (use slug {slug}):
|
|
173
|
+
const SLUG = '{slug}';
|
|
174
|
+
import { fileURLToPath } from 'node:url';
|
|
175
|
+
import * as path from 'node:path';
|
|
176
|
+
const baselineRaw = process.env.CAPTURE_BASELINE === '1' ? '{}' : fs.readFileSync(fileURLToPath(new URL('./baselines/{slug}.json', import.meta.url)), 'utf8');
|
|
177
|
+
const baseline = JSON.parse(baselineRaw);
|
|
178
|
+
const captured: Record<string, unknown> = {};
|
|
179
|
+
process.on('exit', () => {
|
|
180
|
+
if (process.env.CAPTURE_BASELINE === '1') {
|
|
181
|
+
const p = process.env.AUTOREGRESS_TEMP_BASELINE_DIR
|
|
182
|
+
? path.join(process.env.AUTOREGRESS_TEMP_BASELINE_DIR, '{slug}.json')
|
|
183
|
+
: fileURLToPath(new URL('./baselines/{slug}.json', import.meta.url));
|
|
184
|
+
fs.writeFileSync(p, JSON.stringify(captured, null, 2), 'utf8');
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
6. In each test: if (process.env.CAPTURE_BASELINE === '1') { captured['test-name'] = result; return; }
|
|
188
|
+
Else: assert.equal(normalizeSnapshot(result), normalizeSnapshot(baseline['test-name']));
|
|
189
|
+
7. Write 2-4 it() tests covering representative behaviors
|
|
190
|
+
8. Output ONLY the TypeScript file contents, no markdown fences, no explanation`;
|
|
191
|
+
|
|
192
|
+
async function cmdGenerate(args: string[]): Promise<number> {
|
|
193
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
194
|
+
if (!apiKey) { console.error('[autoregress generate] OPENAI_API_KEY not set'); return 1; }
|
|
195
|
+
|
|
196
|
+
const sinceIdx = args.indexOf('--since');
|
|
197
|
+
const since = sinceIdx >= 0 ? args[sinceIdx + 1] : undefined;
|
|
198
|
+
const filesIdx = args.indexOf('--files');
|
|
199
|
+
const filesArg = filesIdx >= 0 ? args[filesIdx + 1] : undefined;
|
|
200
|
+
|
|
201
|
+
let srcFiles: string[];
|
|
202
|
+
if (filesArg) {
|
|
203
|
+
srcFiles = filesArg.split(',').map(f => f.trim()).filter(f => f.startsWith('src/') && f.endsWith('.ts'));
|
|
204
|
+
if (srcFiles.length === 0) {
|
|
205
|
+
console.error('[autoregress generate] --files must contain at least one src/*.ts path');
|
|
206
|
+
return 1;
|
|
207
|
+
}
|
|
208
|
+
} else {
|
|
209
|
+
const changed = getChangedFiles(since);
|
|
210
|
+
if (!changed) { console.error('[autoregress generate] could not determine changed files'); return 1; }
|
|
211
|
+
srcFiles = changed.filter(f => f.startsWith('src/') && f.endsWith('.ts'));
|
|
212
|
+
if (srcFiles.length === 0) {
|
|
213
|
+
console.log('[autoregress generate] no src/*.ts files changed — nothing to generate');
|
|
214
|
+
return 0;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log(`[autoregress generate] generating snapshots for ${srcFiles.length} file(s)`);
|
|
219
|
+
|
|
220
|
+
const client = new OpenAI({ apiKey });
|
|
221
|
+
let sourceCommit = 'unknown';
|
|
222
|
+
try { sourceCommit = execSync('git rev-parse --short HEAD', { cwd: ROOT }).toString().trim(); } catch {}
|
|
223
|
+
const generatedAt = new Date().toISOString();
|
|
224
|
+
|
|
225
|
+
for (const srcFile of srcFiles) {
|
|
226
|
+
const absFile = path.join(ROOT, srcFile);
|
|
227
|
+
if (!fs.existsSync(absFile)) { console.warn(` skip (not found): ${srcFile}`); continue; }
|
|
228
|
+
|
|
229
|
+
const fileContents = fs.readFileSync(absFile, 'utf8');
|
|
230
|
+
const slug = srcFile.replace(/[/\\]/g, '-').replace(/\.ts$/, '');
|
|
231
|
+
|
|
232
|
+
process.stdout.write(` ${srcFile} → ${slug}.snap.ts ... `);
|
|
233
|
+
|
|
234
|
+
const prompt = GENERATE_PROMPT
|
|
235
|
+
.replace(/{filePath}/g, srcFile)
|
|
236
|
+
.replace(/{fileContents}/g, fileContents)
|
|
237
|
+
.replace(/{slug}/g, slug)
|
|
238
|
+
.replace(/{version}/g, GENERATOR_VERSION)
|
|
239
|
+
.replace(/{generatedAt}/g, generatedAt)
|
|
240
|
+
.replace(/{sourceCommit}/g, sourceCommit);
|
|
241
|
+
|
|
242
|
+
let snapContent: string;
|
|
243
|
+
try {
|
|
244
|
+
const response = await client.responses.create({
|
|
245
|
+
model: process.env.CODEX_MODEL ?? 'gpt-5.3-codex',
|
|
246
|
+
instructions: 'You write TypeScript snapshot tests. Output ONLY the file contents, no markdown fences.',
|
|
247
|
+
input: prompt,
|
|
248
|
+
max_output_tokens: 2000,
|
|
249
|
+
});
|
|
250
|
+
snapContent = (response.output_text ?? '').replace(/^```typescript\n?/m, '').replace(/```$/m, '').trim();
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error(`LLM error: ${err instanceof Error ? err.message : String(err)}`);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const snapPath = path.join(SNAPSHOTS_DIR, `${slug}.snap.ts`);
|
|
257
|
+
fs.writeFileSync(snapPath, snapContent + '\n', 'utf8');
|
|
258
|
+
|
|
259
|
+
const captureResult = spawnSync('node', ['--test', '--import', 'tsx', snapPath], {
|
|
260
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
261
|
+
cwd: ROOT,
|
|
262
|
+
env: { ...process.env, CAPTURE_BASELINE: '1' },
|
|
263
|
+
});
|
|
264
|
+
const baselinePath = path.join(BASELINES_DIR, `${slug}.json`);
|
|
265
|
+
console.log(fs.existsSync(baselinePath) ? 'generated + baseline captured' :
|
|
266
|
+
`generated (capture failed: ${captureResult.stderr?.toString().slice(0, 60)})`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Rebuild index.json from @snapshot-for headers
|
|
270
|
+
const newIndex: Record<string, string[]> = {};
|
|
271
|
+
for (const f of fs.readdirSync(SNAPSHOTS_DIR).filter(x => x.endsWith('.snap.ts'))) {
|
|
272
|
+
const snapRelPath = path.join('tests', 'snapshots', f);
|
|
273
|
+
const content = fs.readFileSync(path.join(SNAPSHOTS_DIR, f), 'utf8');
|
|
274
|
+
const sources = [...content.matchAll(/@snapshot-for:\s*(.+)/g)].map(m => m[1]!.trim());
|
|
275
|
+
if (sources.length) newIndex[snapRelPath] = sources;
|
|
276
|
+
}
|
|
277
|
+
fs.writeFileSync(INDEX_PATH, JSON.stringify(newIndex, null, 2) + '\n', 'utf8');
|
|
278
|
+
|
|
279
|
+
// Rebuild import-map.json — prefix keys/values with 'src/' to match repo-relative git diff paths
|
|
280
|
+
const rawImportMap = buildImportMap(path.join(ROOT, 'src'));
|
|
281
|
+
const newImportMap: Record<string, string[]> = {};
|
|
282
|
+
for (const [dep, importers] of Object.entries(rawImportMap)) {
|
|
283
|
+
newImportMap[`src/${dep}`] = importers.map(i => `src/${i}`);
|
|
284
|
+
}
|
|
285
|
+
fs.writeFileSync(IMPORT_MAP_PATH, JSON.stringify(newImportMap, null, 2) + '\n', 'utf8');
|
|
286
|
+
|
|
287
|
+
console.log('\n[autoregress generate] index.json + import-map.json rebuilt');
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function cmdDiff(args: string[]): number {
|
|
292
|
+
const runAll = args.includes('--all');
|
|
293
|
+
const snapIdx = args.indexOf('--snapshot');
|
|
294
|
+
const slug = snapIdx >= 0 ? args[snapIdx + 1] : undefined;
|
|
295
|
+
const sinceIdx = args.indexOf('--since');
|
|
296
|
+
const since = sinceIdx >= 0 ? args[sinceIdx + 1] : undefined;
|
|
297
|
+
|
|
298
|
+
const index = loadJson<Record<string, string[]>>(INDEX_PATH, {});
|
|
299
|
+
const importMap = loadJson<Record<string, string[]>>(IMPORT_MAP_PATH, {});
|
|
300
|
+
const snapFiles = slug
|
|
301
|
+
? [path.join('tests', 'snapshots', `${slug}.snap.ts`)]
|
|
302
|
+
: allSnapFiles();
|
|
303
|
+
|
|
304
|
+
let selected: string[];
|
|
305
|
+
if (runAll || slug || snapFiles.length === 0) {
|
|
306
|
+
selected = snapFiles;
|
|
307
|
+
} else {
|
|
308
|
+
const changed = getChangedFiles(since);
|
|
309
|
+
selected = changed ? selectSnapshots(changed, snapFiles, index, importMap).selected : snapFiles;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (selected.length === 0) {
|
|
313
|
+
console.log('[autoregress diff] no snapshots to diff');
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
318
|
+
const red = useColor ? '\x1b[31m' : '';
|
|
319
|
+
const green = useColor ? '\x1b[32m' : '';
|
|
320
|
+
const reset = useColor ? '\x1b[0m' : '';
|
|
321
|
+
|
|
322
|
+
let changedCount = 0;
|
|
323
|
+
for (const snap of selected) {
|
|
324
|
+
const slug_ = path.basename(snap, '.snap.ts');
|
|
325
|
+
const baselinePath = path.join(BASELINES_DIR, `${slug_}.json`);
|
|
326
|
+
|
|
327
|
+
if (!fs.existsSync(baselinePath)) {
|
|
328
|
+
console.log(` ${snap} — ${red}no baseline${reset}`);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const tmpBaselinesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ar-diff-'));
|
|
333
|
+
const tmpBaselinePath = path.join(tmpBaselinesDir, `${slug_}.json`);
|
|
334
|
+
fs.copyFileSync(baselinePath, tmpBaselinePath);
|
|
335
|
+
|
|
336
|
+
const absSnap = path.join(ROOT, snap);
|
|
337
|
+
const captureResult = spawnSync('node', ['--test', '--import', 'tsx', absSnap], {
|
|
338
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
339
|
+
cwd: ROOT,
|
|
340
|
+
env: { ...process.env, CAPTURE_BASELINE: '1', AUTOREGRESS_TEMP_BASELINE_DIR: tmpBaselinesDir },
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const baselineJson = fs.readFileSync(baselinePath, 'utf8');
|
|
344
|
+
const captureOk = fs.existsSync(tmpBaselinePath);
|
|
345
|
+
const currentJson = captureOk ? fs.readFileSync(tmpBaselinePath, 'utf8') : null;
|
|
346
|
+
if (!captureOk && captureResult.status !== 0) {
|
|
347
|
+
const stderr = captureResult.stderr?.toString().trim();
|
|
348
|
+
if (stderr) console.error(` ${stderr.slice(0, 120)}`);
|
|
349
|
+
}
|
|
350
|
+
fs.rmSync(tmpBaselinesDir, { recursive: true, force: true });
|
|
351
|
+
|
|
352
|
+
if (!currentJson) {
|
|
353
|
+
console.log(` ${snap} — ${red}capture failed${reset}`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const diffLines = diffBaselines(baselineJson, currentJson);
|
|
358
|
+
if (diffLines.length === 0) {
|
|
359
|
+
console.log(` ${snap} — ${green}✓ no changes${reset}`);
|
|
360
|
+
} else {
|
|
361
|
+
changedCount++;
|
|
362
|
+
console.log(` ${snap}`);
|
|
363
|
+
for (const line of diffLines) {
|
|
364
|
+
if (line.startsWith('-')) console.log(` ${red}${line}${reset}`);
|
|
365
|
+
else if (line.startsWith('+')) console.log(` ${green}${line}${reset}`);
|
|
366
|
+
else console.log(` ${line}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return changedCount > 0 ? 1 : 0;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] ?? '')) {
|
|
375
|
+
const [,, subcmd, ...rest] = process.argv;
|
|
376
|
+
switch (subcmd) {
|
|
377
|
+
case 'run': process.exit(cmdRun(rest)); break;
|
|
378
|
+
case 'update': process.exit(cmdUpdate(rest)); break;
|
|
379
|
+
case 'generate': process.exit(await cmdGenerate(rest)); break;
|
|
380
|
+
case 'diff': process.exit(cmdDiff(rest)); break;
|
|
381
|
+
default:
|
|
382
|
+
console.error(`[autoregress] unknown subcommand: ${subcmd ?? '(none)'}`);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
}
|
package/src/cli/hook.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const HOOK_CONTENT = `#!/bin/sh
|
|
5
|
+
# autopilot pre-push hook — runs impact-selected snapshots before push
|
|
6
|
+
npx tsx scripts/autoregress.ts run
|
|
7
|
+
`;
|
|
8
|
+
|
|
9
|
+
function findGitDir(cwd: string): string | null {
|
|
10
|
+
let dir = path.resolve(cwd);
|
|
11
|
+
for (let i = 0; i < 10; i++) {
|
|
12
|
+
const candidate = path.join(dir, '.git');
|
|
13
|
+
if (fs.existsSync(candidate)) {
|
|
14
|
+
const stat = fs.statSync(candidate);
|
|
15
|
+
if (stat.isDirectory()) return candidate;
|
|
16
|
+
if (stat.isFile()) {
|
|
17
|
+
const content = fs.readFileSync(candidate, 'utf8');
|
|
18
|
+
const match = content.match(/^gitdir:\s*(.+)/m);
|
|
19
|
+
if (match) return path.resolve(dir, match[1]!.trim());
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const parent = path.dirname(dir);
|
|
23
|
+
if (parent === dir) return null;
|
|
24
|
+
dir = parent;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runHook(
|
|
30
|
+
sub: string,
|
|
31
|
+
options: { cwd?: string; force?: boolean } = {},
|
|
32
|
+
): Promise<number> {
|
|
33
|
+
const cwd = options.cwd ?? process.cwd();
|
|
34
|
+
const gitDir = findGitDir(cwd);
|
|
35
|
+
|
|
36
|
+
if (!gitDir) {
|
|
37
|
+
console.error('[hook] not inside a git repository');
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const hookPath = path.join(gitDir, 'hooks', 'pre-push');
|
|
42
|
+
|
|
43
|
+
switch (sub) {
|
|
44
|
+
case 'install': {
|
|
45
|
+
if (fs.existsSync(hookPath) && !options.force) {
|
|
46
|
+
console.error(`[hook] pre-push hook already exists at ${hookPath}`);
|
|
47
|
+
console.error(' Use --force to overwrite.');
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
fs.mkdirSync(path.dirname(hookPath), { recursive: true });
|
|
51
|
+
fs.writeFileSync(hookPath, HOOK_CONTENT, 'utf8');
|
|
52
|
+
fs.chmodSync(hookPath, 0o755);
|
|
53
|
+
console.log(`[hook] installed pre-push hook at ${hookPath}`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
case 'uninstall': {
|
|
57
|
+
if (!fs.existsSync(hookPath)) {
|
|
58
|
+
console.log('[hook] no pre-push hook installed');
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
fs.rmSync(hookPath);
|
|
62
|
+
console.log(`[hook] removed ${hookPath}`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
case 'status': {
|
|
66
|
+
if (fs.existsSync(hookPath)) {
|
|
67
|
+
console.log(`[hook] installed at ${hookPath}`);
|
|
68
|
+
console.log(fs.readFileSync(hookPath, 'utf8'));
|
|
69
|
+
} else {
|
|
70
|
+
console.log('[hook] not installed');
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
default:
|
|
75
|
+
console.error(`[hook] unknown subcommand: ${sub}`);
|
|
76
|
+
console.error('Usage: autopilot hook <install|uninstall|status> [--force]');
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -118,6 +118,15 @@ switch (subcommand) {
|
|
|
118
118
|
break;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
case 'hook': {
|
|
122
|
+
const { runHook } = await import('./hook.ts');
|
|
123
|
+
const hookSub = args[1] ?? 'status';
|
|
124
|
+
const force = boolFlag('force');
|
|
125
|
+
const code = await runHook(hookSub, { force });
|
|
126
|
+
process.exit(code);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
default:
|
|
122
131
|
console.error(`\x1b[31m[autopilot] Unknown subcommand: "${subcommand}"\x1b[0m`);
|
|
123
132
|
printUsage();
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const HIGH_IMPACT_PATTERNS = [
|
|
2
|
+
/^src\/core\/pipeline\//,
|
|
3
|
+
/^src\/adapters\//,
|
|
4
|
+
/^src\/core\/findings\//,
|
|
5
|
+
/^src\/core\/config\//,
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
export interface SelectResult {
|
|
9
|
+
selected: string[];
|
|
10
|
+
fullRun: boolean;
|
|
11
|
+
reason: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function selectSnapshots(
|
|
15
|
+
changedFiles: string[],
|
|
16
|
+
allSnapshotFiles: string[],
|
|
17
|
+
index: Record<string, string[]>,
|
|
18
|
+
importMap: Record<string, string[]>,
|
|
19
|
+
options: { highImpactPatterns?: RegExp[]; volumeThreshold?: number } = {},
|
|
20
|
+
): SelectResult {
|
|
21
|
+
const patterns = options.highImpactPatterns ?? HIGH_IMPACT_PATTERNS;
|
|
22
|
+
const volumeThreshold = options.volumeThreshold ?? 10;
|
|
23
|
+
|
|
24
|
+
if (changedFiles.length > volumeThreshold) {
|
|
25
|
+
return { selected: allSnapshotFiles, fullRun: true, reason: 'volume override (>10 files changed)' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const f of changedFiles) {
|
|
29
|
+
for (const p of patterns) {
|
|
30
|
+
if (p.test(f)) {
|
|
31
|
+
return { selected: allSnapshotFiles, fullRun: true, reason: `high-impact path matched: ${f}` };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Build: sourceFile → snapFiles that cover it
|
|
37
|
+
const sourceToSnaps: Record<string, string[]> = {};
|
|
38
|
+
for (const [snapFile, sources] of Object.entries(index)) {
|
|
39
|
+
for (const src of sources) {
|
|
40
|
+
if (!sourceToSnaps[src]) sourceToSnaps[src] = [];
|
|
41
|
+
sourceToSnaps[src]!.push(snapFile);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const selected = new Set<string>();
|
|
46
|
+
for (const changed of changedFiles) {
|
|
47
|
+
for (const snap of sourceToSnaps[changed] ?? []) selected.add(snap);
|
|
48
|
+
for (const importer of importMap[changed] ?? []) {
|
|
49
|
+
for (const snap of sourceToSnaps[importer] ?? []) selected.add(snap);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
selected: [...selected],
|
|
55
|
+
fullRun: false,
|
|
56
|
+
reason: selected.size === 0
|
|
57
|
+
? 'no snapshots matched changed files'
|
|
58
|
+
: `${selected.size} snapshot(s) selected`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const IMPORT_RE = /^(?:import|export)\s+(?:.*?from\s+)?['"]([^'"]+)['"]/gm;
|
|
5
|
+
|
|
6
|
+
function allTsFiles(dir: string): string[] {
|
|
7
|
+
const results: string[] = [];
|
|
8
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
9
|
+
const full = path.join(dir, entry.name);
|
|
10
|
+
if (entry.isDirectory()) results.push(...allTsFiles(full));
|
|
11
|
+
else if (entry.isFile() && entry.name.endsWith('.ts')) results.push(full);
|
|
12
|
+
}
|
|
13
|
+
return results;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resolveImport(importer: string, specifier: string, srcDir: string): string | null {
|
|
17
|
+
if (!specifier.startsWith('.')) return null;
|
|
18
|
+
const abs = path.resolve(path.dirname(importer), specifier);
|
|
19
|
+
const withExt = abs.endsWith('.ts') ? abs : abs + '.ts';
|
|
20
|
+
const rel = path.relative(srcDir, withExt).replace(/\\/g, '/');
|
|
21
|
+
if (rel.startsWith('..')) return null;
|
|
22
|
+
return rel;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function buildImportMap(srcDir: string): Record<string, string[]> {
|
|
26
|
+
const absDir = path.resolve(srcDir);
|
|
27
|
+
const files = allTsFiles(absDir);
|
|
28
|
+
const map: Record<string, string[]> = {};
|
|
29
|
+
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
const relImporter = path.relative(absDir, file).replace(/\\/g, '/');
|
|
32
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
33
|
+
let m: RegExpExecArray | null;
|
|
34
|
+
IMPORT_RE.lastIndex = 0;
|
|
35
|
+
while ((m = IMPORT_RE.exec(content)) !== null) {
|
|
36
|
+
const resolved = resolveImport(file, m[1]!, absDir);
|
|
37
|
+
if (!resolved) continue;
|
|
38
|
+
if (!map[resolved]) map[resolved] = [];
|
|
39
|
+
if (!map[resolved]!.includes(relImporter)) map[resolved]!.push(relImporter);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return map;
|
|
44
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const ISO_TS_RE = /^\d{4}-\d{2}-\d{2}T/;
|
|
2
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-/i;
|
|
3
|
+
|
|
4
|
+
function normalizeValue(value: unknown, cwd?: string): unknown {
|
|
5
|
+
if (typeof value === 'string') {
|
|
6
|
+
if (ISO_TS_RE.test(value)) return '<timestamp>';
|
|
7
|
+
if (UUID_RE.test(value)) return '<uuid>';
|
|
8
|
+
if (cwd && value.startsWith(cwd + '/')) return value.slice(cwd.length + 1);
|
|
9
|
+
return value;
|
|
10
|
+
}
|
|
11
|
+
if (Array.isArray(value)) return value.map(v => normalizeValue(v, cwd));
|
|
12
|
+
if (value !== null && typeof value === 'object') {
|
|
13
|
+
const sorted: Record<string, unknown> = {};
|
|
14
|
+
for (const key of Object.keys(value as object).sort()) {
|
|
15
|
+
sorted[key] = normalizeValue((value as Record<string, unknown>)[key], cwd);
|
|
16
|
+
}
|
|
17
|
+
return sorted;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeSnapshot(value: unknown, cwd?: string): string {
|
|
23
|
+
return JSON.stringify(normalizeValue(value, cwd), null, 2);
|
|
24
|
+
}
|
|
File without changes
|