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.
- package/CHANGELOG.md +36 -1
- package/README.md +169 -80
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +3 -1
- package/rules/mdc/intake-questions.mdc +8 -0
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +7 -5
- package/src/argv.mjs +19 -0
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +155 -113
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +12 -26
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +17 -535
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +15 -1037
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +116 -971
- package/src/bench/run.mjs +164 -881
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +11 -13
- package/src/ci/dependabot.mjs +25 -55
- package/src/commands/agent.mjs +1 -9
- package/src/commands/agenthook.mjs +66 -35
- package/src/commands/bench.mjs +2 -2
- package/src/commands/ci.mjs +11 -13
- package/src/commands/docs.mjs +88 -0
- package/src/commands/gate.mjs +10 -13
- package/src/commands/harness.mjs +3 -3
- package/src/commands/help.mjs +44 -3
- package/src/commands/hooks.mjs +20 -20
- package/src/commands/ignore.mjs +11 -7
- package/src/commands/impact.mjs +4 -8
- package/src/commands/index.mjs +49 -9
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +122 -104
- package/src/commands/lease.mjs +67 -18
- package/src/commands/map.mjs +10 -5
- package/src/commands/mcp.mjs +2 -17
- package/src/commands/repoindex.mjs +8 -8
- package/src/commands/rules.mjs +8 -4
- package/src/commands/shared.mjs +12 -16
- package/src/commands/skills.mjs +5 -6
- package/src/commands/skillscompile.mjs +3 -3
- package/src/commands/skillsinstall.mjs +5 -4
- package/src/commands/slice.mjs +199 -64
- package/src/commands/spec.mjs +78 -62
- package/src/commands/status.mjs +24 -12
- package/src/commands/sync.mjs +14 -14
- package/src/commands/tests.mjs +12 -12
- package/src/commands/tokens.mjs +19 -24
- package/src/commands/upgrade.mjs +8 -4
- package/src/commands/why.mjs +6 -13
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/decisions/index.mjs +3 -2
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +2 -1
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +5 -3
- package/src/hooks/dispatcher.mjs +26 -32
- package/src/hooks/gates.mjs +12 -2
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +2 -11
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +16 -11
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +124 -17
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +49 -9
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -1005
- package/src/{bench → measure}/corpus.mjs +1 -1
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/output/index.mjs +122 -31
- package/src/packs/manage.mjs +15 -9
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/manifest.mjs +10 -435
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +16 -547
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +13 -1
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +0 -7
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +8 -12
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/{bench/process.mjs → sys/proc.mjs} +28 -5
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +1 -1
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +3 -1
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/src/bench/tasks.mjs +0 -36
package/src/bench/mine.mjs
CHANGED
|
@@ -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,
|
|
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 {
|
|
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 {
|
|
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('"', '"')
|
|
231
|
-
.replaceAll(''', "'")
|
|
232
|
-
.replaceAll('<', '<')
|
|
233
|
-
.replaceAll('>', '>')
|
|
234
|
-
.replaceAll('&', '&')
|
|
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:
|
|
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
|
-
|
|
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) {
|