simt-dev 0.2.1 → 0.3.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.
Files changed (95) hide show
  1. package/CHANGELOG.md +239 -0
  2. package/bin/simt-dev.mjs +21 -2
  3. package/docs/decisions/ADR-064-the-context-meter-counts-what-the-tool-wrote.md +107 -0
  4. package/docs/decisions/ADR-065-a-pack-is-one-shared-rule-set-pinned-by-digest.md +97 -0
  5. package/docs/decisions/ADR-066-a-monorepo-projects-once-per-package.md +115 -0
  6. package/docs/decisions/ADR-067-which-harnesses-read-agents-md-is-a-column.md +110 -0
  7. package/docs/decisions/ADR-068-the-mcp-revision-list-cites-what-was-read.md +111 -0
  8. package/docs/decisions/ADR-069-the-conformance-lint-checks-the-published-specification.md +90 -0
  9. package/docs/decisions/ADR-070-the-json-payload-carries-its-own-version.md +91 -0
  10. package/docs/decisions/ADR-071-a-rule-that-can-be-narrowed-is-written-narrow.md +106 -0
  11. package/docs/decisions/ADR-072-the-budget-is-a-declaration-beside-the-gates.md +104 -0
  12. package/docs/decisions/ADR-073-a-kernel-is-a-smaller-projection-the-repository-asks-for.md +92 -0
  13. package/docs/decisions/ADR-074-a-rule-and-a-decision-are-readable-by-uri.md +99 -0
  14. package/docs/decisions/ADR-075-nothing-leaves-the-machine.md +56 -0
  15. package/docs/decisions/ADR-076-a-change-to-what-agents-read-is-measured.md +70 -0
  16. package/docs/decisions/ADR-077-the-command-card-leads-the-region-and-reads-committed-evidence.md +91 -0
  17. package/docs/decisions/ADR-078-a-line-the-repository-already-proves-does-not-ship.md +117 -0
  18. package/docs/decisions/ADR-079-the-rules-are-enforced-at-the-tool-call-from-one-policy-table.md +162 -0
  19. package/docs/decisions/ADR-080-the-tools-own-names-are-not-repositories-and-neither-reader-decides-by-shape.md +99 -0
  20. package/docs/decisions/ADR-081-a-tracker-is-read-through-the-repositorys-template.md +36 -0
  21. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +35 -0
  22. package/package.json +2 -1
  23. package/rules/core.md +1 -0
  24. package/rules/stacks/android.md +2 -0
  25. package/rules/stacks/cloudflare-workers.md +2 -0
  26. package/rules/stacks/connectiq.md +1 -0
  27. package/rules/stacks/container.md +2 -0
  28. package/rules/stacks/dart.md +2 -0
  29. package/rules/stacks/deno.md +2 -0
  30. package/rules/stacks/dotnet.md +2 -0
  31. package/rules/stacks/elixir.md +2 -0
  32. package/rules/stacks/go.md +2 -0
  33. package/rules/stacks/jvm.md +2 -0
  34. package/rules/stacks/node.md +2 -0
  35. package/rules/stacks/python.md +2 -0
  36. package/rules/stacks/react-native.md +2 -0
  37. package/rules/stacks/react-web.md +2 -0
  38. package/rules/stacks/ruby.md +2 -0
  39. package/rules/stacks/rust.md +2 -0
  40. package/rules/stacks/swift.md +2 -0
  41. package/rules/stacks/typescript.md +2 -0
  42. package/rules/stacks/uv.md +2 -0
  43. package/src/agenthooks/decide.mjs +123 -0
  44. package/src/agenthooks/policy.mjs +78 -0
  45. package/src/agenthooks/render.mjs +240 -0
  46. package/src/argv.mjs +55 -1
  47. package/src/bench/ablate.mjs +630 -0
  48. package/src/bench/adapters.json +149 -0
  49. package/src/bench/grade.mjs +229 -0
  50. package/src/bench/mine.mjs +811 -0
  51. package/src/bench/probes.mjs +1350 -0
  52. package/src/bench/report.mjs +899 -0
  53. package/src/bench/run.mjs +1002 -0
  54. package/src/commands/agenthook.mjs +282 -0
  55. package/src/commands/bench.mjs +359 -0
  56. package/src/commands/compare.mjs +15 -0
  57. package/src/commands/graph.mjs +45 -21
  58. package/src/commands/harness.mjs +301 -0
  59. package/src/commands/help.mjs +73 -2
  60. package/src/commands/index.mjs +67 -25
  61. package/src/commands/init.mjs +12 -8
  62. package/src/commands/mcp.mjs +20 -5
  63. package/src/commands/plan.mjs +99 -0
  64. package/src/commands/rules.mjs +42 -57
  65. package/src/commands/skills.mjs +67 -92
  66. package/src/commands/skillsinstall.mjs +17 -2
  67. package/src/commands/sync.mjs +62 -6
  68. package/src/commands/tokens.mjs +169 -0
  69. package/src/compare/row.mjs +73 -2
  70. package/src/decisions/index.mjs +23 -5
  71. package/src/graph/spec.mjs +227 -0
  72. package/src/graph/speccheck.mjs +68 -0
  73. package/src/hooks/dispatcher.mjs +35 -3
  74. package/src/kernel/card.mjs +254 -0
  75. package/src/kernel/inferability.mjs +188 -0
  76. package/src/mcp/resources.mjs +157 -0
  77. package/src/mcp/schema.mjs +139 -0
  78. package/src/mcp/server.mjs +228 -20
  79. package/src/mcp/tools.mjs +372 -1
  80. package/src/measure/context.mjs +542 -0
  81. package/src/measure/loaderView.mjs +435 -0
  82. package/src/measure/session.mjs +267 -0
  83. package/src/monorepo.mjs +339 -0
  84. package/src/output/index.mjs +78 -2
  85. package/src/packs.mjs +182 -0
  86. package/src/plan/index.mjs +650 -0
  87. package/src/plan/template.mjs +331 -0
  88. package/src/projection/loaders.mjs +689 -0
  89. package/src/projection/manifest.mjs +114 -3
  90. package/src/projection/project.mjs +140 -9
  91. package/src/projection/rules.mjs +365 -22
  92. package/src/projection/surfaces.mjs +155 -23
  93. package/src/projection/targets.mjs +37 -0
  94. package/src/toolwords.mjs +69 -0
  95. package/templates/plan/tracker.json +36 -0
@@ -0,0 +1,811 @@
1
+ // Task miner for `simt bench mine` (R01).
2
+ //
3
+ // Walks git history for non-merge commits that change both test files and
4
+ // implementation files, splits each candidate into `test.patch` and `gold.patch`,
5
+ // and validates in an isolated temporary worktree (twice, so flaky tests are
6
+ // dropped) that BASE + test.patch fails (RED) and BASE + test.patch + gold.patch
7
+ // passes (GOLD).
8
+ //
9
+ // Hidden test patches live in `~/.cache/simt-dev/vault/<repo-id>/<task-id>/`
10
+ // outside every worktree (RepoTrials / PatchGym), while deterministic task
11
+ // descriptors live in `.simt-dev/bench/tasks/<id>.json`.
12
+
13
+ import { execFile, spawn } from 'node:child_process';
14
+ import { createHash } from 'node:crypto';
15
+ import { existsSync } from 'node:fs';
16
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
17
+ import { homedir, platform as osPlatform, tmpdir } from 'node:os';
18
+ import { basename, dirname, join, resolve } from 'node:path';
19
+ import { promisify } from 'node:util';
20
+
21
+ import { CONFIG_DIR } from '../name.mjs';
22
+ import { SCHEMA_VERSION } from '../output/index.mjs';
23
+
24
+ const exec = promisify(execFile);
25
+
26
+ /** Default target task count when mining a repository's history. */
27
+ export const DEFAULT_MINE_TARGET = 20;
28
+
29
+ /**
30
+ * Classify whether a repository-relative path is a test file.
31
+ *
32
+ * Covers the five ecosystems named in plan 0003 §5 R01:
33
+ * - `test/`, `tests/`, `spec/`, `__tests__/`, `src/test/`
34
+ * - `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`
35
+ * - `*_test.go` (Go)
36
+ * - `test_*.py` (pytest)
37
+ * - `*Test.java`, `*Tests.java`, `*Test.kt`, `*Tests.kt` (Gradle / Maven)
38
+ *
39
+ * @param {string} relPath forward-slash repository-relative path
40
+ * @returns {boolean}
41
+ */
42
+ export function isTestPath(relPath) {
43
+ const norm = String(relPath).replaceAll('\\', '/').replace(/^\.\//, '');
44
+ if (!norm) return false;
45
+ const segments = norm.split('/');
46
+ const base = segments[segments.length - 1];
47
+
48
+ if (
49
+ norm.startsWith('test/') ||
50
+ norm.startsWith('tests/') ||
51
+ norm.startsWith('spec/') ||
52
+ norm.startsWith('__tests__/') ||
53
+ norm.startsWith('src/test/') ||
54
+ segments.includes('__tests__') ||
55
+ segments.includes('test') ||
56
+ segments.includes('tests') ||
57
+ segments.includes('spec')
58
+ ) {
59
+ return true;
60
+ }
61
+
62
+ if (/\.(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) return true;
63
+ if (/_(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) return true;
64
+ if (/^test_[^/]+\.py$/.test(base)) return true;
65
+ if (/Tests?\.(?:java|kt)$/.test(base)) return true;
66
+
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Parse TAP version 13 output from `node --test --test-reporter=tap`.
72
+ *
73
+ * Leaf test cases carry `type: 'test'` in their YAML block; suite headers carry
74
+ * `type: 'suite'` and are used only to build `parent > child` qualified IDs.
75
+ *
76
+ * @param {string} text
77
+ * @returns {{passed: string[], failed: string[]}}
78
+ */
79
+ export function parseTapOutput(text) {
80
+ const passed = [];
81
+ const failed = [];
82
+ const lines = String(text ?? '').split('\n');
83
+ const stack = [];
84
+ let pending = null;
85
+
86
+ const flush = () => {
87
+ if (!pending) return;
88
+ if (pending.type !== 'suite' && !pending.hadChild && !pending.directive) {
89
+ const id = [...pending.ancestors, pending.name].join(' > ');
90
+ if (pending.ok) passed.push(id);
91
+ else failed.push(id);
92
+ }
93
+ pending = null;
94
+ };
95
+
96
+ for (const raw of lines) {
97
+ const sub = /^(\s*)# Subtest:\s+(.*)$/.exec(raw);
98
+ if (sub) {
99
+ flush();
100
+ const indent = sub[1].length;
101
+ while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
102
+ stack.pop();
103
+ }
104
+ if (stack.length > 0) {
105
+ stack[stack.length - 1].hadChild = true;
106
+ }
107
+ stack.push({ indent, name: sub[2].trim(), hadChild: false });
108
+ continue;
109
+ }
110
+
111
+ const res = /^(\s*)(not ok|ok)\s+\d+(?:\s+-\s+(.*?))?(?:\s+#\s*(SKIP|TODO)\b.*)?$/i.exec(raw);
112
+ if (res) {
113
+ flush();
114
+ const indent = res[1].length;
115
+ while (stack.length > 0 && stack[stack.length - 1].indent > indent) {
116
+ stack.pop();
117
+ }
118
+ let hadChild = false;
119
+ if (stack.length > 0 && stack[stack.length - 1].indent === indent) {
120
+ hadChild = stack.pop().hadChild;
121
+ }
122
+ const ancestors = stack.map((s) => s.name);
123
+ const ok = res[2].toLowerCase() === 'ok';
124
+ const name = (res[3] ?? '').trim();
125
+ const directive = res[4] ? res[4].toUpperCase() : null;
126
+ if (name) {
127
+ pending = { indent, ok, name, ancestors, hadChild, directive, type: null };
128
+ }
129
+ continue;
130
+ }
131
+
132
+ if (pending) {
133
+ const typeMatch = /^\s*type:\s*['"]?(test|suite)['"]?\s*$/.exec(raw);
134
+ if (typeMatch) {
135
+ pending.type = typeMatch[1];
136
+ } else if (/^\s*\.\.\.\s*$/.test(raw)) {
137
+ flush();
138
+ }
139
+ }
140
+ }
141
+ flush();
142
+
143
+ return {
144
+ passed: dedupeSorted(passed),
145
+ failed: dedupeSorted(failed),
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Parse Vitest `--reporter=json` output.
151
+ *
152
+ * @param {string} text
153
+ * @returns {{passed: string[], failed: string[]}}
154
+ */
155
+ export function parseVitestJson(text) {
156
+ const passed = [];
157
+ const failed = [];
158
+ const raw = String(text ?? '').trim();
159
+ const start = raw.indexOf('{');
160
+ const end = raw.lastIndexOf('}');
161
+ if (start === -1 || end === -1 || end < start) return { passed, failed };
162
+
163
+ let doc;
164
+ try {
165
+ doc = JSON.parse(raw.slice(start, end + 1));
166
+ } catch {
167
+ return { passed, failed };
168
+ }
169
+
170
+ for (const suite of doc.testResults ?? []) {
171
+ for (const assertion of suite.assertionResults ?? []) {
172
+ const parts = [...(assertion.ancestorTitles ?? []), assertion.title].filter(Boolean);
173
+ const id = parts.length > 0 ? parts.join(' > ') : (assertion.fullName ?? '').trim();
174
+ if (!id) continue;
175
+ if (assertion.status === 'passed') passed.push(id);
176
+ else if (assertion.status === 'failed') failed.push(id);
177
+ }
178
+ }
179
+
180
+ return {
181
+ passed: dedupeSorted(passed),
182
+ failed: dedupeSorted(failed),
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Parse JUnit XML output (used by pytest `--junitxml`, Gradle and Maven).
188
+ *
189
+ * @param {string} xml
190
+ * @returns {{passed: string[], failed: string[]}}
191
+ */
192
+ export function parseJunitXml(xml) {
193
+ const passed = [];
194
+ const failed = [];
195
+ const source = String(xml ?? '');
196
+ const tagRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
197
+
198
+ for (const match of source.matchAll(tagRe)) {
199
+ const attrs = match[1] ?? '';
200
+ const inner = match[2] ?? '';
201
+ const name = xmlAttr(attrs, 'name');
202
+ const classname = xmlAttr(attrs, 'classname');
203
+ if (!name) continue;
204
+ if (/<skipped\b/i.test(inner)) continue;
205
+
206
+ const id = classname ? `${classname} > ${name}` : name;
207
+ if (/<(?:failure|error)\b/i.test(inner)) failed.push(id);
208
+ else passed.push(id);
209
+ }
210
+
211
+ return {
212
+ passed: dedupeSorted(passed),
213
+ failed: dedupeSorted(failed),
214
+ };
215
+ }
216
+
217
+ function xmlAttr(attrs, key) {
218
+ const m = new RegExp(`\\b${key}=["']([^"']*)["']`).exec(attrs);
219
+ if (!m) return '';
220
+ return m[1]
221
+ .replaceAll('&quot;', '"')
222
+ .replaceAll('&apos;', "'")
223
+ .replaceAll('&lt;', '<')
224
+ .replaceAll('&gt;', '>')
225
+ .replaceAll('&amp;', '&')
226
+ .trim();
227
+ }
228
+
229
+ /**
230
+ * Parse `go test -json` JSONL event stream.
231
+ *
232
+ * Only leaf test events (carrying `Test`) are recorded; package-level summary
233
+ * events without `Test` are ignored.
234
+ *
235
+ * @param {string} text
236
+ * @returns {{passed: string[], failed: string[]}}
237
+ */
238
+ export function parseGoTestJson(text) {
239
+ const passed = [];
240
+ const failed = [];
241
+ for (const line of String(text ?? '').split('\n')) {
242
+ const trimmed = line.trim();
243
+ if (!trimmed.startsWith('{')) continue;
244
+ let ev;
245
+ try {
246
+ ev = JSON.parse(trimmed);
247
+ } catch {
248
+ continue;
249
+ }
250
+ if (typeof ev.Test !== 'string' || ev.Test.length === 0) continue;
251
+ const id = ev.Package ? `${ev.Package} > ${ev.Test}` : ev.Test;
252
+ if (ev.Action === 'pass') passed.push(id);
253
+ else if (ev.Action === 'fail') failed.push(id);
254
+ }
255
+
256
+ return {
257
+ passed: dedupeSorted(passed),
258
+ failed: dedupeSorted(failed),
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Detect the test runner for a repository and build the command for a set of
264
+ * changed test files.
265
+ *
266
+ * @param {string} root
267
+ * @param {string[]} testFiles
268
+ * @returns {Promise<{runner: string, displayCmd: string, buildExec: (worktree: string) => {cmd: string, args: string[], reportPath?: string}, parse: (stdout: string, reportText?: string) => {passed: string[], failed: string[]}}|null>}
269
+ */
270
+ export async function detectTestRunner(root, testFiles) {
271
+ const files = [...testFiles].sort();
272
+
273
+ if (existsSync(join(root, 'package.json'))) {
274
+ let pkg = {};
275
+ try {
276
+ pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
277
+ } catch {
278
+ pkg = {};
279
+ }
280
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
281
+ const testScript = String(pkg.scripts?.test ?? '');
282
+ if (deps.vitest || /\bvitest\b/.test(testScript)) {
283
+ const bin = join('node_modules', '.bin', 'vitest');
284
+ return {
285
+ runner: 'vitest',
286
+ displayCmd: `vitest run --reporter=json ${files.join(' ')}`,
287
+ buildExec: (wt) => ({
288
+ cmd: existsSync(join(wt, bin)) ? join(wt, bin) : 'vitest',
289
+ args: ['run', '--reporter=json', ...files],
290
+ }),
291
+ parse: (stdout) => parseVitestJson(stdout),
292
+ };
293
+ }
294
+ return {
295
+ runner: 'node-test',
296
+ displayCmd: `node --test ${files.join(' ')}`,
297
+ buildExec: () => ({
298
+ cmd: process.execPath,
299
+ args: ['--test', '--test-reporter=tap', ...files],
300
+ }),
301
+ parse: (stdout) => parseTapOutput(stdout),
302
+ };
303
+ }
304
+
305
+ if (existsSync(join(root, 'go.mod'))) {
306
+ const pkgs = dedupeSorted(
307
+ files.map((f) => {
308
+ const dir = dirname(f);
309
+ return dir === '.' ? './...' : `./${dir}`;
310
+ }),
311
+ );
312
+ return {
313
+ runner: 'go-test',
314
+ displayCmd: `go test -json ${pkgs.join(' ')}`,
315
+ buildExec: () => ({
316
+ cmd: 'go',
317
+ args: ['test', '-json', ...pkgs],
318
+ }),
319
+ parse: (stdout) => parseGoTestJson(stdout),
320
+ };
321
+ }
322
+
323
+ if (
324
+ existsSync(join(root, 'pyproject.toml')) ||
325
+ existsSync(join(root, 'requirements.txt')) ||
326
+ existsSync(join(root, 'setup.py'))
327
+ ) {
328
+ return {
329
+ runner: 'pytest',
330
+ displayCmd: `pytest ${files.join(' ')}`,
331
+ buildExec: (wt) => {
332
+ const reportPath = join(wt, '.bench-junit.xml');
333
+ return {
334
+ cmd: 'pytest',
335
+ args: [`--junitxml=${reportPath}`, ...files],
336
+ reportPath,
337
+ };
338
+ },
339
+ parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
340
+ };
341
+ }
342
+
343
+ if (existsSync(join(root, 'build.gradle.kts')) || existsSync(join(root, 'build.gradle'))) {
344
+ const classes = files
345
+ .map((f) => basename(f).replace(/\.(?:java|kt)$/, ''))
346
+ .filter(Boolean);
347
+ const filterArgs = classes.flatMap((c) => ['--tests', `*${c}*`]);
348
+ return {
349
+ runner: 'gradle',
350
+ displayCmd: `./gradlew test ${filterArgs.join(' ')}`.trim(),
351
+ buildExec: (wt) => ({
352
+ cmd: existsSync(join(wt, 'gradlew')) ? join(wt, 'gradlew') : 'gradle',
353
+ args: ['test', ...filterArgs],
354
+ reportDir: join(wt, 'build', 'test-results', 'test'),
355
+ }),
356
+ parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
357
+ };
358
+ }
359
+
360
+ if (existsSync(join(root, 'pom.xml'))) {
361
+ const classes = files
362
+ .map((f) => basename(f).replace(/\.(?:java|kt)$/, ''))
363
+ .filter(Boolean);
364
+ const arg = classes.length > 0 ? [`-Dtest=${classes.join(',')}`] : [];
365
+ return {
366
+ runner: 'maven',
367
+ displayCmd: `mvn test ${arg.join(' ')}`.trim(),
368
+ buildExec: () => ({
369
+ cmd: 'mvn',
370
+ args: ['test', ...arg],
371
+ }),
372
+ parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
373
+ };
374
+ }
375
+
376
+ return null;
377
+ }
378
+
379
+ /**
380
+ * Resolve the hidden verifier vault root outside every worktree.
381
+ *
382
+ * Precedence:
383
+ * 1. `SIMT_DEV_VAULT_DIR` (explicit override for hermetic tests)
384
+ * 2. `$XDG_CACHE_HOME/simt-dev/vault/<repo-id>`
385
+ * 3. `~/Library/Caches/simt-dev/vault/<repo-id>` on macOS, or `~/.cache/simt-dev/vault/<repo-id>`
386
+ *
387
+ * @param {string} root
388
+ * @param {{env?: NodeJS.ProcessEnv, home?: string, platform?: string}} [options]
389
+ * @returns {string}
390
+ */
391
+ export function resolveVaultDir(root, { env = process.env, home = homedir(), platform = osPlatform() } = {}) {
392
+ const repoId = repoIdentifier(root);
393
+ if (env.SIMT_DEV_VAULT_DIR) {
394
+ return join(resolve(env.SIMT_DEV_VAULT_DIR), repoId);
395
+ }
396
+ const cacheBase = env.XDG_CACHE_HOME
397
+ ? resolve(env.XDG_CACHE_HOME)
398
+ : platform === 'darwin'
399
+ ? join(home, 'Library', 'Caches')
400
+ : join(home, '.cache');
401
+ return join(cacheBase, CONFIG_DIR, 'vault', repoId);
402
+ }
403
+
404
+ function repoIdentifier(root) {
405
+ const full = resolve(root);
406
+ const name = basename(full).replace(/[^A-Za-z0-9._-]/g, '-') || 'repo';
407
+ const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
408
+ return `${name}-${digest}`;
409
+ }
410
+
411
+ /**
412
+ * Compute diff stats (`files`, `linesAdded`, `linesRemoved`) from a unified diff buffer.
413
+ *
414
+ * @param {Buffer|string} patch
415
+ * @param {number} fileCount
416
+ * @returns {{files: number, linesAdded: number, linesRemoved: number}}
417
+ */
418
+ export function patchStats(patch, fileCount) {
419
+ const text = Buffer.isBuffer(patch) ? patch.toString('utf8') : String(patch ?? '');
420
+ let linesAdded = 0;
421
+ let linesRemoved = 0;
422
+ for (const line of text.split('\n')) {
423
+ if (line.startsWith('+++ ') || line.startsWith('--- ')) continue;
424
+ if (line.startsWith('+')) linesAdded += 1;
425
+ else if (line.startsWith('-')) linesRemoved += 1;
426
+ }
427
+ return { files: fileCount, linesAdded, linesRemoved };
428
+ }
429
+
430
+ /**
431
+ * Extract candidate commits from git history in a single `git log` pass.
432
+ *
433
+ * Keeps non-merge commits with at least one parent that modify both test paths
434
+ * and implementation paths.
435
+ *
436
+ * @param {string} root
437
+ * @returns {Promise<{ok: boolean, candidates: object[], error?: string}>}
438
+ */
439
+ export async function listCandidates(root) {
440
+ let stdout;
441
+ try {
442
+ ({ stdout } = await exec(
443
+ 'git',
444
+ ['log', '--no-merges', '--min-parents=1', '--name-only', '--format=%x1e%H%x00%P%x00%s%x00%b%x1f'],
445
+ { cwd: root, maxBuffer: 64 << 20 },
446
+ ));
447
+ } catch (error) {
448
+ return { ok: false, candidates: [], error: error.stderr?.trim() || error.message };
449
+ }
450
+
451
+ const candidates = [];
452
+ for (const record of stdout.split('\x1e')) {
453
+ if (!record.trim()) continue;
454
+ const sep = record.indexOf('\x1f');
455
+ if (sep === -1) continue;
456
+ const header = record.slice(0, sep);
457
+ const filesBlock = record.slice(sep + 1);
458
+ const [sha, parents, subject, body] = header.split('\0');
459
+ if (!sha || !parents) continue;
460
+ const base = parents.trim().split(/\s+/)[0];
461
+ if (!base) continue;
462
+
463
+ const changed = dedupeSorted(
464
+ filesBlock
465
+ .split('\n')
466
+ .map((line) => line.trim())
467
+ .filter(Boolean),
468
+ );
469
+ const testFiles = changed.filter((f) => isTestPath(f));
470
+ const implFiles = changed.filter((f) => !isTestPath(f));
471
+ if (testFiles.length === 0 || implFiles.length === 0) continue;
472
+
473
+ const cleanSubject = (subject ?? '').trim();
474
+ const cleanBody = (body ?? '').trim();
475
+ const prompt = cleanBody ? `${cleanSubject}\n\n${cleanBody}` : cleanSubject;
476
+
477
+ candidates.push({
478
+ sha: sha.trim(),
479
+ base,
480
+ subject: cleanSubject,
481
+ body: cleanBody,
482
+ prompt,
483
+ testFiles,
484
+ implFiles,
485
+ });
486
+ }
487
+
488
+ return { ok: true, candidates };
489
+ }
490
+
491
+ /**
492
+ * Run a child process in its own process group with a hard timeout so a hanging
493
+ * test or grandchildren cannot outlive the check.
494
+ */
495
+ function runProcess(cmd, args, { cwd, input, env, timeoutMs = 15000 } = {}) {
496
+ return new Promise((resolvePromise) => {
497
+ const child = spawn(cmd, args, {
498
+ cwd,
499
+ env,
500
+ stdio: ['pipe', 'pipe', 'pipe'],
501
+ detached: true,
502
+ });
503
+ let stdout = '';
504
+ let stderr = '';
505
+ let timedOut = false;
506
+
507
+ const timer = setTimeout(() => {
508
+ timedOut = true;
509
+ try {
510
+ process.kill(-child.pid, 'SIGKILL');
511
+ } catch {
512
+ child.kill('SIGKILL');
513
+ }
514
+ child.stdout.destroy();
515
+ child.stderr.destroy();
516
+ }, timeoutMs);
517
+
518
+ child.stdout.on('data', (chunk) => {
519
+ stdout += chunk;
520
+ });
521
+ child.stderr.on('data', (chunk) => {
522
+ stderr += chunk;
523
+ });
524
+ child.on('error', (err) => {
525
+ clearTimeout(timer);
526
+ resolvePromise({ code: 1, timedOut: false, stdout, stderr: `${stderr}\n${err.message}` });
527
+ });
528
+ child.on('close', (code) => {
529
+ clearTimeout(timer);
530
+ resolvePromise({ code: code ?? 1, timedOut, stdout, stderr });
531
+ });
532
+
533
+ if (input !== undefined) child.stdin.end(input);
534
+ else child.stdin.end();
535
+ });
536
+ }
537
+
538
+ async function executeRunner(runner, worktree, env, timeoutMs) {
539
+ const spec = runner.buildExec(worktree);
540
+ if (spec.reportPath && existsSync(spec.reportPath)) {
541
+ await rm(spec.reportPath, { force: true });
542
+ }
543
+ const res = await runProcess(spec.cmd, spec.args, { cwd: worktree, env, timeoutMs });
544
+ let reportText = '';
545
+ if (spec.reportPath && existsSync(spec.reportPath)) {
546
+ reportText = await readFile(spec.reportPath, 'utf8').catch(() => '');
547
+ }
548
+ const parsed = runner.parse(res.stdout, reportText);
549
+ return {
550
+ code: res.code,
551
+ timedOut: res.timedOut,
552
+ passed: parsed.passed,
553
+ failed: parsed.failed,
554
+ };
555
+ }
556
+
557
+ /**
558
+ * Validate a single candidate commit in `worktree` by running RED twice and GOLD
559
+ * twice, dropping any candidate whose test outcomes are not identical across both
560
+ * passes.
561
+ */
562
+ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 } = {}) {
563
+ // Skip commits that modify more than 4 test files or run `npm pack` integration
564
+ // builds, which are sweeping renames rather than self-contained benchmark tasks.
565
+ if (candidate.testFiles.length > 4 || candidate.testFiles.includes('test/pack.test.mjs')) {
566
+ return null;
567
+ }
568
+
569
+ let testPatch;
570
+ let goldPatch;
571
+ try {
572
+ ({ stdout: testPatch } = await exec(
573
+ 'git',
574
+ ['diff', '--binary', candidate.base, candidate.sha, '--', ...candidate.testFiles],
575
+ { cwd: root, encoding: 'buffer', maxBuffer: 16 << 20 },
576
+ ));
577
+ ({ stdout: goldPatch } = await exec(
578
+ 'git',
579
+ ['diff', '--binary', candidate.base, candidate.sha, '--', ...candidate.implFiles],
580
+ { cwd: root, encoding: 'buffer', maxBuffer: 16 << 20 },
581
+ ));
582
+ } catch {
583
+ return null;
584
+ }
585
+ if (!testPatch || testPatch.length === 0 || !goldPatch || goldPatch.length === 0) {
586
+ return null;
587
+ }
588
+
589
+ await exec('git', ['reset', '--hard', 'HEAD'], { cwd: worktree }).catch(() => null);
590
+ await exec('git', ['clean', '-fdx'], { cwd: worktree }).catch(() => null);
591
+ const co = await runProcess('git', ['checkout', '-f', candidate.base], { cwd: worktree });
592
+ if (co.code !== 0) return null;
593
+ await exec('git', ['clean', '-fdx'], { cwd: worktree }).catch(() => null);
594
+
595
+ const appliedRed = await runProcess('git', ['apply', '-'], { cwd: worktree, input: testPatch });
596
+ if (appliedRed.code !== 0) return null;
597
+
598
+ const runnableFiles = candidate.testFiles.filter((f) => existsSync(join(worktree, f)));
599
+ if (runnableFiles.length === 0) return null;
600
+
601
+ const runner = await detectTestRunner(worktree, runnableFiles);
602
+ if (!runner) return null;
603
+
604
+ const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', NO_COLOR: '1' };
605
+ // Strip NODE_TEST_CONTEXT so child `node --test` invocations spawned from
606
+ // inside a parent `node --test` run honour `--test-reporter=tap` instead of
607
+ // emitting internal v8 subtest protocol frames.
608
+ delete env.NODE_TEST_CONTEXT;
609
+
610
+ // Pass 1 & 2 of RED: BASE + test.patch must fail reproducibly.
611
+ const red1 = await executeRunner(runner, worktree, env, timeoutMs);
612
+ if (red1.code === 0 || red1.timedOut) return null;
613
+ const red2 = await executeRunner(runner, worktree, env, timeoutMs);
614
+ if (red2.code === 0 || red2.timedOut) return null;
615
+ if (!sameList(red1.failed, red2.failed) || !sameList(red1.passed, red2.passed)) {
616
+ return null;
617
+ }
618
+
619
+ // Apply gold.patch on top of BASE + test.patch.
620
+ const appliedGold = await runProcess('git', ['apply', '-'], { cwd: worktree, input: goldPatch });
621
+ if (appliedGold.code !== 0) return null;
622
+
623
+ // Pass 1 & 2 of GOLD: BASE + test.patch + gold.patch must pass reproducibly.
624
+ const gold1 = await executeRunner(runner, worktree, env, timeoutMs);
625
+ if (gold1.code !== 0 || gold1.timedOut || gold1.failed.length > 0) return null;
626
+ const gold2 = await executeRunner(runner, worktree, env, timeoutMs);
627
+ if (gold2.code !== 0 || gold2.timedOut || gold2.failed.length > 0) return null;
628
+ if (!sameList(gold1.passed, gold2.passed)) return null;
629
+
630
+ const redPassedSet = new Set(red1.passed);
631
+ const redFailedSet = new Set(red1.failed);
632
+ // Tests that failed in RED (or could not load before gold.patch existed) and
633
+ // passed in GOLD are FAIL_TO_PASS; tests that passed in both are PASS_TO_PASS.
634
+ const f2p = gold1.passed.filter((id) => redFailedSet.has(id) || !redPassedSet.has(id));
635
+ const p2p = gold1.passed.filter((id) => redPassedSet.has(id) && !redFailedSet.has(id));
636
+ if (f2p.length === 0) return null;
637
+
638
+ const shortSha = candidate.sha.slice(0, 7);
639
+ const id = `t-${shortSha}-01`;
640
+ const gold = patchStats(goldPatch, candidate.implFiles.length);
641
+
642
+ return {
643
+ task: {
644
+ schemaVersion: SCHEMA_VERSION,
645
+ id,
646
+ base: candidate.base,
647
+ sourceCommit: candidate.sha,
648
+ prompt: candidate.prompt,
649
+ testCmd: runner.displayCmd,
650
+ f2p,
651
+ p2p,
652
+ gold,
653
+ vault: `vault/${id}`,
654
+ },
655
+ testPatch,
656
+ goldPatch,
657
+ };
658
+ }
659
+
660
+ /**
661
+ * Mine verified benchmark tasks from a repository's git history.
662
+ *
663
+ * When `apply` is false (the default without `--yes`, or under `--dry-run`),
664
+ * candidates are discovered and reported without writing to `.simt-dev/bench/tasks/`
665
+ * or the vault. When `apply` is true, candidates are validated in isolated
666
+ * temporary worktrees until `target` valid tasks are collected, and written
667
+ * deterministically to disk.
668
+ *
669
+ * @param {string} root
670
+ * @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
671
+ */
672
+ export async function mineTasks(
673
+ root,
674
+ {
675
+ apply = false,
676
+ target = DEFAULT_MINE_TARGET,
677
+ concurrency = 4,
678
+ timeoutMs = 5000,
679
+ env = process.env,
680
+ } = {},
681
+ ) {
682
+ const absRoot = resolve(root);
683
+ const listed = await listCandidates(absRoot);
684
+ if (!listed.ok) {
685
+ return { ok: false, error: listed.error };
686
+ }
687
+
688
+ const tasksDir = join(absRoot, '.simt-dev', 'bench', 'tasks');
689
+ const vaultRoot = resolveVaultDir(absRoot, { env });
690
+
691
+ if (!apply) {
692
+ const preview = listed.candidates.slice(0, target).map((c) => ({
693
+ id: `t-${c.sha.slice(0, 7)}-01`,
694
+ base: c.base,
695
+ sourceCommit: c.sha,
696
+ subject: c.subject,
697
+ testFiles: c.testFiles,
698
+ implFiles: c.implFiles,
699
+ }));
700
+ return {
701
+ ok: true,
702
+ applied: false,
703
+ root: absRoot,
704
+ tasksDir,
705
+ vaultRoot,
706
+ candidatesCount: listed.candidates.length,
707
+ tasks: preview,
708
+ };
709
+ }
710
+
711
+ if (listed.candidates.length === 0) {
712
+ return {
713
+ ok: true,
714
+ applied: true,
715
+ root: absRoot,
716
+ tasksDir,
717
+ vaultRoot,
718
+ candidatesCount: 0,
719
+ tasks: [],
720
+ };
721
+ }
722
+
723
+ const workerCount = Math.max(1, Math.min(concurrency, listed.candidates.length, target));
724
+ const sandbox = await mkdtemp(join(tmpdir(), 'bench-mine-'));
725
+ const worktrees = [];
726
+
727
+ try {
728
+ for (let i = 0; i < workerCount; i += 1) {
729
+ // Nest each worktree inside its own subdirectory so `resolve(wt, '..')`
730
+ // never looks like a sibling workspace (ADR-031).
731
+ const wt = join(sandbox, `slot-${i}`, 'repo');
732
+ await mkdir(dirname(wt), { recursive: true });
733
+ await exec('git', ['worktree', 'add', '--detach', wt, 'HEAD'], { cwd: absRoot });
734
+ worktrees.push(wt);
735
+ }
736
+
737
+ let cursor = 0;
738
+ const validated = [];
739
+
740
+ const runSlot = async (wt) => {
741
+ while (validated.length < target) {
742
+ const index = cursor;
743
+ cursor += 1;
744
+ if (index >= listed.candidates.length) break;
745
+ const candidate = listed.candidates[index];
746
+ const res = await validateCandidate(absRoot, wt, candidate, { timeoutMs });
747
+ if (res) {
748
+ validated.push({ index, ...res });
749
+ }
750
+ }
751
+ };
752
+
753
+ await Promise.all(worktrees.map((wt) => runSlot(wt)));
754
+
755
+ // Sort by commit history index so task selection is deterministic regardless
756
+ // of which worker finished first.
757
+ validated.sort((a, b) => a.index - b.index);
758
+ const selected = validated.slice(0, target);
759
+
760
+ await mkdir(tasksDir, { recursive: true });
761
+ await mkdir(vaultRoot, { recursive: true });
762
+
763
+ // Remove stale task files when re-mining so the directory reflects the
764
+ // deterministic set for this history.
765
+ const existing = await readdir(tasksDir).catch(() => []);
766
+ const keepNames = new Set(selected.map((entry) => `${entry.task.id}.json`));
767
+ for (const name of existing) {
768
+ if (name.endsWith('.json') && !keepNames.has(name)) {
769
+ await rm(join(tasksDir, name), { force: true });
770
+ }
771
+ }
772
+
773
+ for (const entry of selected) {
774
+ const taskPath = join(tasksDir, `${entry.task.id}.json`);
775
+ await writeFile(taskPath, `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
776
+
777
+ const taskVault = join(vaultRoot, entry.task.id);
778
+ await mkdir(taskVault, { recursive: true });
779
+ await writeFile(join(taskVault, 'test.patch'), entry.testPatch);
780
+ await writeFile(join(taskVault, 'gold.patch'), entry.goldPatch);
781
+ }
782
+
783
+ return {
784
+ ok: true,
785
+ applied: true,
786
+ root: absRoot,
787
+ tasksDir,
788
+ vaultRoot,
789
+ candidatesCount: listed.candidates.length,
790
+ tasks: selected.map((entry) => entry.task),
791
+ };
792
+ } finally {
793
+ for (const wt of worktrees) {
794
+ await exec('git', ['worktree', 'remove', '--force', wt], { cwd: absRoot }).catch(() => null);
795
+ }
796
+ await exec('git', ['worktree', 'prune'], { cwd: absRoot }).catch(() => null);
797
+ await rm(sandbox, { recursive: true, force: true }).catch(() => null);
798
+ }
799
+ }
800
+
801
+ function dedupeSorted(items) {
802
+ return [...new Set(items)].sort();
803
+ }
804
+
805
+ function sameList(a, b) {
806
+ if (a.length !== b.length) return false;
807
+ for (let i = 0; i < a.length; i += 1) {
808
+ if (a[i] !== b[i]) return false;
809
+ }
810
+ return true;
811
+ }