simt-dev 0.7.1 → 0.8.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 (186) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +169 -80
  3. package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
  4. package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
  5. package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
  6. package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
  7. package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
  8. package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
  9. package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
  10. package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
  11. package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
  12. package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
  13. package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
  14. package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
  15. package/ecosystems/bun.json +6 -0
  16. package/ecosystems/cargo.json +25 -0
  17. package/ecosystems/composer.json +6 -0
  18. package/ecosystems/dart.json +24 -0
  19. package/ecosystems/deno.json +21 -0
  20. package/ecosystems/devcontainers.json +6 -0
  21. package/ecosystems/docker-compose.json +6 -0
  22. package/ecosystems/docker.json +6 -0
  23. package/ecosystems/dotnet.json +25 -0
  24. package/ecosystems/elixir.json +23 -0
  25. package/ecosystems/gitsubmodule.json +6 -0
  26. package/ecosystems/go.json +25 -0
  27. package/ecosystems/gradle.json +13 -0
  28. package/ecosystems/maven.json +23 -0
  29. package/ecosystems/node.json +25 -0
  30. package/ecosystems/python.json +20 -0
  31. package/ecosystems/ruby.json +23 -0
  32. package/ecosystems/swift.json +25 -0
  33. package/ecosystems/terraform.json +6 -0
  34. package/ecosystems/uv.json +6 -0
  35. package/harnesses/antigravity.json +162 -0
  36. package/harnesses/claude.json +206 -0
  37. package/harnesses/codex.json +136 -0
  38. package/harnesses/copilot.json +88 -0
  39. package/harnesses/cursor.json +140 -0
  40. package/harnesses/deepseek.json +99 -0
  41. package/harnesses/gemini.json +122 -0
  42. package/harnesses/opencode.json +173 -0
  43. package/package.json +3 -1
  44. package/rules/mdc/intake-questions.mdc +8 -0
  45. package/src/advice/advice.mjs +3 -2
  46. package/src/agenthooks/render.mjs +86 -180
  47. package/src/agents/render.mjs +15 -18
  48. package/src/api.mjs +7 -5
  49. package/src/argv.mjs +19 -0
  50. package/src/bench/ablate.mjs +3 -4
  51. package/src/bench/adapters.json +155 -113
  52. package/src/bench/adapters.mjs +466 -0
  53. package/src/bench/arms.mjs +333 -0
  54. package/src/bench/grade.mjs +12 -26
  55. package/src/bench/graders.mjs +42 -0
  56. package/src/bench/mine.mjs +17 -535
  57. package/src/bench/probe-ledger.mjs +149 -0
  58. package/src/bench/probes.catalog.mjs +918 -0
  59. package/src/bench/probes.mjs +15 -1037
  60. package/src/bench/render.mjs +111 -0
  61. package/src/bench/report.mjs +116 -971
  62. package/src/bench/run.mjs +164 -881
  63. package/src/bench/variants.mjs +4 -5
  64. package/src/bench/workspace.mjs +160 -0
  65. package/src/brief/assemble.mjs +11 -13
  66. package/src/ci/dependabot.mjs +25 -55
  67. package/src/commands/agent.mjs +1 -9
  68. package/src/commands/agenthook.mjs +66 -35
  69. package/src/commands/bench.mjs +2 -2
  70. package/src/commands/ci.mjs +11 -13
  71. package/src/commands/docs.mjs +88 -0
  72. package/src/commands/gate.mjs +10 -13
  73. package/src/commands/harness.mjs +3 -3
  74. package/src/commands/help.mjs +44 -3
  75. package/src/commands/hooks.mjs +20 -20
  76. package/src/commands/ignore.mjs +11 -7
  77. package/src/commands/impact.mjs +4 -8
  78. package/src/commands/index.mjs +49 -9
  79. package/src/commands/init-scan.mjs +90 -0
  80. package/src/commands/init.mjs +122 -104
  81. package/src/commands/lease.mjs +67 -18
  82. package/src/commands/map.mjs +10 -5
  83. package/src/commands/mcp.mjs +2 -17
  84. package/src/commands/repoindex.mjs +8 -8
  85. package/src/commands/rules.mjs +8 -4
  86. package/src/commands/shared.mjs +12 -16
  87. package/src/commands/skills.mjs +5 -6
  88. package/src/commands/skillscompile.mjs +3 -3
  89. package/src/commands/skillsinstall.mjs +5 -4
  90. package/src/commands/slice.mjs +199 -64
  91. package/src/commands/spec.mjs +78 -62
  92. package/src/commands/status.mjs +24 -12
  93. package/src/commands/sync.mjs +14 -14
  94. package/src/commands/tests.mjs +12 -12
  95. package/src/commands/tokens.mjs +19 -24
  96. package/src/commands/upgrade.mjs +8 -4
  97. package/src/commands/why.mjs +6 -13
  98. package/src/compare/hooksstate.mjs +2 -6
  99. package/src/compare/learn.mjs +3 -1
  100. package/src/decisions/index.mjs +3 -2
  101. package/src/docs/commands.mjs +65 -0
  102. package/src/ecosystem/records.mjs +219 -0
  103. package/src/explain.mjs +31 -0
  104. package/src/gates/index.mjs +385 -0
  105. package/src/graph/compile.mjs +8 -4
  106. package/src/graph/load.mjs +2 -1
  107. package/src/graph/spec.mjs +10 -8
  108. package/src/harness/records.mjs +143 -0
  109. package/src/hooks/capability.mjs +5 -3
  110. package/src/hooks/dispatcher.mjs +26 -32
  111. package/src/hooks/gates.mjs +12 -2
  112. package/src/hooks/resolve.mjs +2 -7
  113. package/src/hooks/where.mjs +2 -7
  114. package/src/identity.mjs +2 -11
  115. package/src/index/coverage.mjs +58 -0
  116. package/src/index/extract.mjs +6 -16
  117. package/src/index/gate.mjs +1 -1
  118. package/src/index/govern.mjs +26 -9
  119. package/src/index/impact.mjs +4 -8
  120. package/src/index/map.mjs +14 -15
  121. package/src/index/open.mjs +173 -0
  122. package/src/index/persist.mjs +224 -57
  123. package/src/index/store.mjs +82 -39
  124. package/src/index/tests.mjs +2 -6
  125. package/src/journal.mjs +16 -11
  126. package/src/kernel/card.mjs +37 -63
  127. package/src/kernel/checks.mjs +4 -10
  128. package/src/lease.mjs +102 -7
  129. package/src/ledger/index.mjs +124 -17
  130. package/src/mcp/paginate.mjs +33 -0
  131. package/src/mcp/schemas.mjs +512 -0
  132. package/src/mcp/server.mjs +49 -9
  133. package/src/mcp/tools-facts.mjs +326 -0
  134. package/src/mcp/tools-read.mjs +221 -0
  135. package/src/mcp/tools.mjs +4 -1005
  136. package/src/{bench → measure}/corpus.mjs +1 -1
  137. package/src/measure/metrics.mjs +236 -0
  138. package/src/measure/records.mjs +211 -0
  139. package/src/measure/stats.mjs +538 -0
  140. package/src/measure/task-schema.mjs +125 -0
  141. package/src/measure/tasks.mjs +69 -0
  142. package/src/memory/decide.mjs +6 -15
  143. package/src/memory/lesson-text.mjs +61 -0
  144. package/src/memory/lessons.mjs +29 -82
  145. package/src/name.mjs +7 -0
  146. package/src/output/index.mjs +122 -31
  147. package/src/packs/manage.mjs +15 -9
  148. package/src/plan/index.mjs +1 -1
  149. package/src/plugins.mjs +22 -16
  150. package/src/prbody.mjs +7 -11
  151. package/src/projection/loaders.mjs +7 -692
  152. package/src/projection/manifest.mjs +10 -435
  153. package/src/projection/mdc.mjs +7 -8
  154. package/src/projection/project.mjs +126 -221
  155. package/src/projection/region.mjs +561 -0
  156. package/src/projection/rules.mjs +16 -547
  157. package/src/projection/surfaces.mjs +43 -210
  158. package/src/projection/targets.mjs +5 -5
  159. package/src/repo-root.mjs +13 -1
  160. package/src/resolve/eval.mjs +2 -1
  161. package/src/resolve/fingerprint.mjs +0 -7
  162. package/src/resolve/resolve.mjs +1 -41
  163. package/src/review/critic.mjs +8 -12
  164. package/src/run.mjs +98 -0
  165. package/src/secrets.mjs +14 -22
  166. package/src/state/events.mjs +129 -0
  167. package/src/status-snapshot.mjs +233 -0
  168. package/src/status.mjs +2 -6
  169. package/src/sys/git.mjs +82 -0
  170. package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
  171. package/src/testing/parsers.mjs +265 -0
  172. package/src/testing/patches.mjs +23 -0
  173. package/src/testing/paths.mjs +49 -0
  174. package/src/testing/runners.mjs +186 -0
  175. package/src/testing/vault.mjs +68 -0
  176. package/src/text/frontmatter.mjs +49 -0
  177. package/src/text/tokens.mjs +52 -0
  178. package/src/toolwords.mjs +1 -1
  179. package/src/util/fs.mjs +20 -0
  180. package/src/util/sort.mjs +12 -0
  181. package/src/workspace.mjs +3 -1
  182. package/src/write/plan.mjs +82 -0
  183. package/templates/plan/tracker.json +1 -0
  184. package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
  185. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
  186. package/src/bench/tasks.mjs +0 -36
@@ -10,22 +10,24 @@
10
10
  // outside every worktree (RepoTrials / PatchGym), while deterministic task
11
11
  // descriptors live in `.simt/bench/tasks/<id>.json`.
12
12
 
13
- import { execFile, execFileSync, spawn } from 'node:child_process';
13
+ import { execFile, spawn } from 'node:child_process';
14
14
  import { createHash } from 'node:crypto';
15
15
  import { existsSync } from 'node:fs';
16
16
  import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
17
- import { homedir, platform as osPlatform, tmpdir } from 'node:os';
17
+ import { tmpdir } from 'node:os';
18
18
  import { basename, dirname, join, resolve } from 'node:path';
19
19
  import { promisify } from 'node:util';
20
20
 
21
- import { CONFIG_DIR, STATE_DIR } from '../name.mjs';
22
- import { SCHEMA_VERSION } from '../output/index.mjs';
21
+ import { STATE_DIR } from '../name.mjs';
23
22
  import { readManifest } from '../projection/manifest.mjs';
24
23
  import { packageVersion } from '../package-version.mjs';
24
+ import { TASK_SCHEMA_VERSION, normaliseTask } from '../measure/task-schema.mjs';
25
+ import { isTestPath } from '../testing/paths.mjs';
26
+ import { cleanupReports, dedupeSorted, readReports } from '../testing/parsers.mjs';
27
+ import { detectTestRunner } from '../testing/runners.mjs';
28
+ import { resolveVaultDir } from '../testing/vault.mjs';
25
29
 
26
30
  const exec = promisify(execFile);
27
-
28
- /** Default target task count when mining a repository's history. */
29
31
  export const DEFAULT_MINE_TARGET = 20;
30
32
 
31
33
  /**
@@ -33,528 +35,6 @@ export const DEFAULT_MINE_TARGET = 20;
33
35
  * (review 0003 Q48: the pack integration test was hardcoded here, a repo fact, not a miner fact).
34
36
  */
35
37
  const DEFAULT_EXCLUDES = ['test/pack.test.mjs'];
36
-
37
- /**
38
- * Classify whether a repository-relative path is a test file.
39
- *
40
- * Covers the ecosystems named in plan 0003 §5 R01:
41
- * - `test/`, `tests/`, `spec/`, `__tests__/`, `src/test/`
42
- * - `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`
43
- * - `*_test.go` (Go)
44
- * - `test_*.py` (pytest)
45
- * - `tests/*.rs` (Cargo integration tests)
46
- * - `*Test.java`, `*Tests.java`, `*Test.kt`, `*Tests.kt` (Gradle / Maven)
47
- *
48
- * @param {string} relPath forward-slash repository-relative path
49
- * @returns {boolean}
50
- */
51
- export function isTestPath(relPath) {
52
- const norm = String(relPath).replaceAll('\\', '/').replace(/^\.\//, '');
53
- if (!norm) return false;
54
- const segments = norm.split('/');
55
- const base = segments[segments.length - 1];
56
-
57
- if (
58
- norm.startsWith('test/') ||
59
- norm.startsWith('tests/') ||
60
- norm.startsWith('spec/') ||
61
- norm.startsWith('__tests__/') ||
62
- norm.startsWith('src/test/') ||
63
- segments.includes('__tests__') ||
64
- segments.includes('test') ||
65
- segments.includes('tests') ||
66
- segments.includes('spec')
67
- ) {
68
- return true;
69
- }
70
-
71
- if (/\.(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) return true;
72
- if (/_(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) return true;
73
- if (/^test_[^/]+\.py$/.test(base)) return true;
74
- if (/Tests?\.(?:java|kt)$/.test(base)) return true;
75
-
76
- return false;
77
- }
78
-
79
- /**
80
- * Parse TAP version 13 output from `node --test --test-reporter=tap`.
81
- *
82
- * Leaf test cases carry `type: 'test'` in their YAML block; suite headers carry
83
- * `type: 'suite'` and are used only to build `parent > child` qualified IDs.
84
- *
85
- * @param {string} text
86
- * @returns {{passed: string[], failed: string[]}}
87
- */
88
- export function parseTapOutput(text) {
89
- const passed = [];
90
- const failed = [];
91
- const lines = String(text ?? '').split('\n');
92
- const stack = [];
93
- let pending = null;
94
-
95
- const flush = () => {
96
- if (!pending) return;
97
- if (pending.type !== 'suite' && !pending.hadChild && !pending.directive) {
98
- const id = [...pending.ancestors, pending.name].join(' > ');
99
- if (pending.ok) passed.push(id);
100
- else failed.push(id);
101
- }
102
- pending = null;
103
- };
104
-
105
- for (const raw of lines) {
106
- const sub = /^(\s*)# Subtest:\s+(.*)$/.exec(raw);
107
- if (sub) {
108
- flush();
109
- const indent = sub[1].length;
110
- while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
111
- stack.pop();
112
- }
113
- if (stack.length > 0) {
114
- stack[stack.length - 1].hadChild = true;
115
- }
116
- stack.push({ indent, name: sub[2].trim(), hadChild: false });
117
- continue;
118
- }
119
-
120
- const res = /^(\s*)(not ok|ok)\s+\d+(?:\s+-\s+(.*?))?(?:\s+#\s*(SKIP|TODO)\b.*)?$/i.exec(raw);
121
- if (res) {
122
- flush();
123
- const indent = res[1].length;
124
- while (stack.length > 0 && stack[stack.length - 1].indent > indent) {
125
- stack.pop();
126
- }
127
- let hadChild = false;
128
- if (stack.length > 0 && stack[stack.length - 1].indent === indent) {
129
- hadChild = stack.pop().hadChild;
130
- }
131
- const ancestors = stack.map((s) => s.name);
132
- const ok = res[2].toLowerCase() === 'ok';
133
- const name = (res[3] ?? '').trim();
134
- const directive = res[4] ? res[4].toUpperCase() : null;
135
- if (name) {
136
- pending = { indent, ok, name, ancestors, hadChild, directive, type: null };
137
- }
138
- continue;
139
- }
140
-
141
- if (pending) {
142
- const typeMatch = /^\s*type:\s*['"]?(test|suite)['"]?\s*$/.exec(raw);
143
- if (typeMatch) {
144
- pending.type = typeMatch[1];
145
- } else if (/^\s*\.\.\.\s*$/.test(raw)) {
146
- flush();
147
- }
148
- }
149
- }
150
- flush();
151
-
152
- return {
153
- passed: dedupeSorted(passed),
154
- failed: dedupeSorted(failed),
155
- };
156
- }
157
-
158
- /**
159
- * Parse Vitest `--reporter=json` output.
160
- *
161
- * @param {string} text
162
- * @returns {{passed: string[], failed: string[]}}
163
- */
164
- export function parseVitestJson(text) {
165
- const passed = [];
166
- const failed = [];
167
- const raw = String(text ?? '').trim();
168
- const start = raw.indexOf('{');
169
- const end = raw.lastIndexOf('}');
170
- if (start === -1 || end === -1 || end < start) return { passed, failed };
171
-
172
- let doc;
173
- try {
174
- doc = JSON.parse(raw.slice(start, end + 1));
175
- } catch {
176
- return { passed, failed };
177
- }
178
-
179
- for (const suite of doc.testResults ?? []) {
180
- for (const assertion of suite.assertionResults ?? []) {
181
- const parts = [...(assertion.ancestorTitles ?? []), assertion.title].filter(Boolean);
182
- const id = parts.length > 0 ? parts.join(' > ') : (assertion.fullName ?? '').trim();
183
- if (!id) continue;
184
- if (assertion.status === 'passed') passed.push(id);
185
- else if (assertion.status === 'failed') failed.push(id);
186
- }
187
- }
188
-
189
- return {
190
- passed: dedupeSorted(passed),
191
- failed: dedupeSorted(failed),
192
- };
193
- }
194
-
195
- /**
196
- * Parse JUnit XML output (used by pytest `--junitxml`, Gradle and Maven).
197
- *
198
- * @param {string} xml
199
- * @returns {{passed: string[], failed: string[]}}
200
- */
201
- export function parseJunitXml(xml) {
202
- const passed = [];
203
- const failed = [];
204
- const source = String(xml ?? '');
205
- const tagRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
206
-
207
- for (const match of source.matchAll(tagRe)) {
208
- const attrs = match[1] ?? '';
209
- const inner = match[2] ?? '';
210
- const name = xmlAttr(attrs, 'name');
211
- const classname = xmlAttr(attrs, 'classname');
212
- if (!name) continue;
213
- if (/<skipped\b/i.test(inner)) continue;
214
-
215
- const id = classname ? `${classname} > ${name}` : name;
216
- if (/<(?:failure|error)\b/i.test(inner)) failed.push(id);
217
- else passed.push(id);
218
- }
219
-
220
- return {
221
- passed: dedupeSorted(passed),
222
- failed: dedupeSorted(failed),
223
- };
224
- }
225
-
226
- function xmlAttr(attrs, key) {
227
- const m = new RegExp(`\\b${key}=["']([^"']*)["']`).exec(attrs);
228
- if (!m) return '';
229
- return m[1]
230
- .replaceAll('&quot;', '"')
231
- .replaceAll('&apos;', "'")
232
- .replaceAll('&lt;', '<')
233
- .replaceAll('&gt;', '>')
234
- .replaceAll('&amp;', '&')
235
- .trim();
236
- }
237
-
238
- /**
239
- * Parse `go test -json` JSONL event stream.
240
- *
241
- * Only leaf test events (carrying `Test`) are recorded; package-level summary
242
- * events without `Test` are ignored.
243
- *
244
- * @param {string} text
245
- * @returns {{passed: string[], failed: string[]}}
246
- */
247
- export function parseGoTestJson(text) {
248
- const passed = [];
249
- const failed = [];
250
- for (const line of String(text ?? '').split('\n')) {
251
- const trimmed = line.trim();
252
- if (!trimmed.startsWith('{')) continue;
253
- let ev;
254
- try {
255
- ev = JSON.parse(trimmed);
256
- } catch {
257
- continue;
258
- }
259
- if (typeof ev.Test !== 'string' || ev.Test.length === 0) continue;
260
- const id = ev.Package ? `${ev.Package} > ${ev.Test}` : ev.Test;
261
- if (ev.Action === 'pass') passed.push(id);
262
- else if (ev.Action === 'fail') failed.push(id);
263
- }
264
-
265
- return {
266
- passed: dedupeSorted(passed),
267
- failed: dedupeSorted(failed),
268
- };
269
- }
270
-
271
- /**
272
- * Parse libtest's text output, which `cargo test` prints for every test binary.
273
- *
274
- * A line is a result only when it has the `test <name> ... <status>` shape.
275
- * Harness summaries (`test result: ok.`), compile output and doctest prose are
276
- * not results. `ignored` is neither a pass nor a failure: counting it as one
277
- * would make a skipped test look like evidence the change works.
278
- *
279
- * @param {string} text
280
- * @returns {{passed: string[], failed: string[]}}
281
- */
282
- export function parseCargoTestOutput(text) {
283
- const passed = [];
284
- const failed = [];
285
- for (const line of String(text ?? '').split('\n')) {
286
- const match = /^test\s+(.+?)\s+\.\.\.\s+(ok|FAILED|ignored)\s*$/.exec(line.trimEnd());
287
- if (!match || match[2] === 'ignored') continue;
288
- (match[2] === 'ok' ? passed : failed).push(match[1]);
289
- }
290
- return {
291
- passed: dedupeSorted(passed),
292
- failed: dedupeSorted(failed),
293
- };
294
- }
295
-
296
- /**
297
- * Read every JUnit XML a run left behind, from report files or report
298
- * *directories* (review 0003 Q48: Gradle writes one XML per class under
299
- * `build/test-results/test/`, Maven under `target/surefire-reports/`; reading
300
- * a single file saw none of them, so both graders read nothing). Directories
301
- * are walked recursively in sort order so the concatenation is deterministic.
302
- *
303
- * @param {string[]} paths report files and/or directories
304
- * @returns {Promise<string>} the concatenated XML, '' when nothing is there
305
- */
306
- export async function readReports(paths) {
307
- const chunks = [];
308
- for (const p of paths ?? []) {
309
- let isDir = false;
310
- try {
311
- isDir = (await stat(p)).isDirectory();
312
- } catch {
313
- continue;
314
- }
315
- if (isDir) {
316
- const files = await listXml(p);
317
- for (const file of files) {
318
- chunks.push(await readFile(file, 'utf8').catch(() => ''));
319
- }
320
- } else {
321
- chunks.push(await readFile(p, 'utf8').catch(() => ''));
322
- }
323
- }
324
- return chunks.join('\n');
325
- }
326
-
327
- /** Every `*.xml` under `dir`, sorted, for a deterministic read order. */
328
- async function listXml(dir) {
329
- const out = [];
330
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
331
- for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
332
- const full = join(dir, entry.name);
333
- if (entry.isDirectory()) out.push(...(await listXml(full)));
334
- else if (entry.name.endsWith('.xml')) out.push(full);
335
- }
336
- return out;
337
- }
338
-
339
- /**
340
- * Detect the test runner for a repository and build the command for a set of
341
- * changed test files.
342
- *
343
- * @param {string} root
344
- * @param {string[]} testFiles
345
- * @returns {Promise<{runner: string, displayCmd: string, buildExec: (worktree: string) => {cmd: string, args: string[], reportPaths?: string[]}, parse: (stdout: string, reportText?: string) => {passed: string[], failed: string[]}}|null>}
346
- */
347
- export async function detectTestRunner(root, testFiles) {
348
- const files = [...testFiles].sort();
349
-
350
- if (existsSync(join(root, 'package.json'))) {
351
- let pkg = {};
352
- try {
353
- pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
354
- } catch {
355
- pkg = {};
356
- }
357
- const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
358
- const testScript = String(pkg.scripts?.test ?? '');
359
- if (deps.vitest || /\bvitest\b/.test(testScript)) {
360
- const bin = join('node_modules', '.bin', 'vitest');
361
- return {
362
- runner: 'vitest',
363
- displayCmd: `vitest run --reporter=json ${files.join(' ')}`,
364
- buildExec: (wt) => ({
365
- cmd: existsSync(join(wt, bin)) ? join(wt, bin) : 'vitest',
366
- args: ['run', '--reporter=json', ...files],
367
- }),
368
- parse: (stdout) => parseVitestJson(stdout),
369
- };
370
- }
371
- // Jest emits the same `testResults`/`assertionResults` JSON shape as
372
- // Vitest (Vitest copied it), so the JSON parser serves both (review 0003
373
- // Q48: a jest repository fell through to `node --test` and parsed TAP
374
- // out of JSON).
375
- if (deps.jest || /\bjest\b/.test(testScript)) {
376
- const bin = join('node_modules', '.bin', 'jest');
377
- return {
378
- runner: 'jest',
379
- displayCmd: `jest --json ${files.join(' ')}`,
380
- buildExec: (wt) => ({
381
- cmd: existsSync(join(wt, bin)) ? join(wt, bin) : 'jest',
382
- args: ['--json', ...files],
383
- }),
384
- parse: (stdout) => parseVitestJson(stdout),
385
- };
386
- }
387
- return {
388
- runner: 'node-test',
389
- displayCmd: `node --test ${files.join(' ')}`,
390
- buildExec: () => ({
391
- cmd: process.execPath,
392
- args: ['--test', '--test-reporter=tap', ...files],
393
- }),
394
- parse: (stdout) => parseTapOutput(stdout),
395
- };
396
- }
397
-
398
- if (existsSync(join(root, 'Cargo.toml'))) {
399
- // Cargo has no "run this file" argument. An integration test is a target
400
- // named after its file in `tests/`, so it can be selected with `--test`.
401
- // A source file carries inline unit tests and can only be reached by the
402
- // crate's test binary, so one such file means the whole crate runs.
403
- const targets = dedupeSorted(
404
- files.filter((f) => /^tests\/[^/]+\.rs$/.test(f)).map((f) => basename(f, '.rs')),
405
- );
406
- const integrationOnly = files.length > 0 && files.every((f) => /^tests\/[^/]+\.rs$/.test(f));
407
- const args = ['test'];
408
- if (integrationOnly) {
409
- for (const target of targets) args.push('--test', target);
410
- }
411
- return {
412
- runner: 'cargo-test',
413
- displayCmd: ['cargo', ...args].join(' '),
414
- buildExec: () => ({ cmd: 'cargo', args }),
415
- parse: (stdout) => parseCargoTestOutput(stdout),
416
- };
417
- }
418
-
419
- if (existsSync(join(root, 'go.mod'))) {
420
- const pkgs = dedupeSorted(
421
- files.map((f) => {
422
- const dir = dirname(f);
423
- return dir === '.' ? './...' : `./${dir}`;
424
- }),
425
- );
426
- return {
427
- runner: 'go-test',
428
- displayCmd: `go test -json ${pkgs.join(' ')}`,
429
- buildExec: () => ({
430
- cmd: 'go',
431
- args: ['test', '-json', ...pkgs],
432
- }),
433
- parse: (stdout) => parseGoTestJson(stdout),
434
- };
435
- }
436
-
437
- if (
438
- existsSync(join(root, 'pyproject.toml')) ||
439
- existsSync(join(root, 'requirements.txt')) ||
440
- existsSync(join(root, 'setup.py'))
441
- ) {
442
- return {
443
- runner: 'pytest',
444
- displayCmd: `pytest ${files.join(' ')}`,
445
- buildExec: (wt) => {
446
- const reportPath = join(wt, '.bench-junit.xml');
447
- return {
448
- cmd: 'pytest',
449
- args: [`--junitxml=${reportPath}`, ...files],
450
- reportPaths: [reportPath],
451
- };
452
- },
453
- parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
454
- };
455
- }
456
-
457
- if (existsSync(join(root, 'build.gradle.kts')) || existsSync(join(root, 'build.gradle'))) {
458
- const classes = files
459
- .map((f) => basename(f).replace(/\.(?:java|kt)$/, ''))
460
- .filter(Boolean);
461
- const filterArgs = classes.flatMap((c) => ['--tests', `*${c}*`]);
462
- return {
463
- runner: 'gradle',
464
- displayCmd: `./gradlew test ${filterArgs.join(' ')}`.trim(),
465
- buildExec: (wt) => ({
466
- cmd: existsSync(join(wt, 'gradlew')) ? join(wt, 'gradlew') : 'gradle',
467
- args: ['test', ...filterArgs],
468
- // One XML per test class; readReports walks the directory.
469
- reportPaths: [join(wt, 'build', 'test-results', 'test')],
470
- }),
471
- parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
472
- };
473
- }
474
-
475
- if (existsSync(join(root, 'pom.xml'))) {
476
- const classes = files
477
- .map((f) => basename(f).replace(/\.(?:java|kt)$/, ''))
478
- .filter(Boolean);
479
- const arg = classes.length > 0 ? [`-Dtest=${classes.join(',')}`] : [];
480
- return {
481
- runner: 'maven',
482
- displayCmd: `mvn test ${arg.join(' ')}`.trim(),
483
- buildExec: (wt) => ({
484
- cmd: 'mvn',
485
- args: ['test', ...arg],
486
- // Surefire writes one XML per class here; never read before (review 0003 Q48).
487
- reportPaths: [join(wt, 'target', 'surefire-reports')],
488
- }),
489
- parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
490
- };
491
- }
492
-
493
- return null;
494
- }
495
-
496
- /**
497
- * Resolve the hidden verifier vault root outside every worktree.
498
- *
499
- * Precedence:
500
- * 1. `SIMT_DEV_VAULT_DIR` (explicit override for hermetic tests)
501
- * 2. `$XDG_CACHE_HOME/simt-dev/vault/<repo-id>`
502
- * 3. `~/Library/Caches/simt-dev/vault/<repo-id>` on macOS, or `~/.cache/simt-dev/vault/<repo-id>`
503
- *
504
- * @param {string} root
505
- * @param {{env?: NodeJS.ProcessEnv, home?: string, platform?: string}} [options]
506
- * @returns {string}
507
- */
508
- export function resolveVaultDir(root, { env = process.env, home = homedir(), platform = osPlatform() } = {}) {
509
- const repoId = repoIdentifier(root);
510
- if (env.SIMT_DEV_VAULT_DIR) {
511
- return join(resolve(env.SIMT_DEV_VAULT_DIR), repoId);
512
- }
513
- const cacheBase = env.XDG_CACHE_HOME
514
- ? resolve(env.XDG_CACHE_HOME)
515
- : platform === 'darwin'
516
- ? join(home, 'Library', 'Caches')
517
- : join(home, '.cache');
518
- return join(cacheBase, CONFIG_DIR, 'vault', repoId);
519
- }
520
-
521
- /**
522
- * The stable identity a repository's vault is keyed on: the basename for a
523
- * human-readable prefix, and the root commit (first commit in history) for the
524
- * digest. The root commit is identical in every clone and survives remote
525
- * renames, so the vault lands in the same directory on every machine with the
526
- * same history (review 0003 Q48: the absolute-path key made the committed
527
- * corpus unrunnable from any other checkout). Outside git it degrades to the
528
- * absolute-path digest: a root with no history has no better identity, and the
529
- * vault path must never fail because git is absent.
530
- *
531
- * Sync, like package-version.mjs: the vault path is computed on every read, and
532
- * a path that throws is worse than one that is occasionally less specific.
533
- */
534
- function repoIdentifier(root) {
535
- const full = resolve(root);
536
- const name = basename(full).replace(/[^A-Za-z0-9._-]/g, '-') || 'repo';
537
- try {
538
- const sha = execFileSync('git', ['rev-list', '--max-parents=0', 'HEAD'], {
539
- cwd: root,
540
- encoding: 'utf8',
541
- stdio: ['ignore', 'pipe', 'ignore'],
542
- }).trim().split(/\s/)[0];
543
- if (/^[0-9a-f]{40}$/.test(sha)) return `${name}-root-${sha.slice(0, 8)}`;
544
- } catch {
545
- // Not a git root (or no commits): fall through to the path digest.
546
- }
547
- const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
548
- return `${name}-${digest}`;
549
- }
550
-
551
- /**
552
- * Compute diff stats (`files`, `linesAdded`, `linesRemoved`) from a unified diff buffer.
553
- *
554
- * @param {Buffer|string} patch
555
- * @param {number} fileCount
556
- * @returns {{files: number, linesAdded: number, linesRemoved: number}}
557
- */
558
38
  export function patchStats(patch, fileCount) {
559
39
  const text = Buffer.isBuffer(patch) ? patch.toString('utf8') : String(patch ?? '');
560
40
  let linesAdded = 0;
@@ -690,6 +170,7 @@ async function executeRunner(runner, worktree, env, timeoutMs) {
690
170
  const res = await runProcess(spec.cmd, spec.args, { cwd: worktree, env, timeoutMs });
691
171
  const reportText = await readReports(spec.reportPaths);
692
172
  const parsed = runner.parse(res.stdout, reportText);
173
+ await cleanupReports(spec.reportPaths);
693
174
  return {
694
175
  code: res.code,
695
176
  timedOut: res.timedOut,
@@ -797,7 +278,7 @@ async function validateCandidate(
797
278
 
798
279
  return {
799
280
  task: {
800
- schemaVersion: SCHEMA_VERSION,
281
+ schemaVersion: TASK_SCHEMA_VERSION,
801
282
  id,
802
283
  base: candidate.base,
803
284
  sourceCommit: candidate.sha,
@@ -968,7 +449,13 @@ export async function mineTasks(
968
449
  };
969
450
  }
970
451
  }
971
- await writeFile(taskPath, `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
452
+ // The miner is the writer, so it writes the schema a reader validates: a task file
453
+ // that only becomes valid on load is a file two readers can disagree about.
454
+ await writeFile(
455
+ taskPath,
456
+ `${JSON.stringify(normaliseTask(entry.task, { source: taskPath }), null, 2)}\n`,
457
+ 'utf8',
458
+ );
972
459
 
973
460
  const taskVault = join(vaultRoot, entry.task.id);
974
461
  await mkdir(taskVault, { recursive: true, mode: 0o700 });
@@ -1008,11 +495,6 @@ export async function mineTasks(
1008
495
  await rm(sandbox, { recursive: true, force: true }).catch(() => null);
1009
496
  }
1010
497
  }
1011
-
1012
- function dedupeSorted(items) {
1013
- return [...new Set(items)].sort();
1014
- }
1015
-
1016
498
  function sameList(a, b) {
1017
499
  if (a.length !== b.length) return false;
1018
500
  for (let i = 0; i < a.length; i += 1) {