simt-dev 0.7.0 → 0.7.1
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 +158 -872
- package/README.md +19 -133
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +10 -2
- package/rules/core.md +7 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/shim.mjs +1 -1
- package/src/api.mjs +29 -0
- package/src/argv.mjs +29 -3
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +199 -35
- package/src/bench/probes.mjs +99 -62
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +17 -4
- package/src/bench/run.mjs +242 -142
- package/src/brief/assemble.mjs +38 -3
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +3 -4
- package/src/commands/agenthook.mjs +4 -5
- package/src/commands/bench.mjs +190 -17
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +16 -12
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +125 -17
- package/src/commands/hooks.mjs +100 -30
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +4 -7
- package/src/commands/index.mjs +273 -188
- package/src/commands/init.mjs +2 -1
- package/src/commands/lease.mjs +6 -7
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +14 -9
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +4 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +33 -16
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +28 -26
- package/src/commands/skillscompile.mjs +4 -5
- package/src/commands/skillsinstall.mjs +2 -2
- package/src/commands/slice.mjs +21 -10
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +13 -11
- package/src/commands/tests.mjs +6 -9
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +7 -3
- package/src/commands/why.mjs +9 -8
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/graph/load.mjs +4 -1
- package/src/hooks/capability.mjs +3 -3
- package/src/hooks/dispatcher.mjs +198 -43
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/shim.mjs +5 -2
- package/src/identity.mjs +29 -1
- package/src/journal.mjs +78 -9
- package/src/ledger/index.mjs +30 -1
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/server.mjs +70 -16
- package/src/mcp/tools.mjs +45 -7
- package/src/measure/session.mjs +3 -2
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +20 -34
- package/src/projection/rules.mjs +4 -3
- package/src/repo-root.mjs +19 -0
- package/src/resolve/fingerprint.mjs +19 -15
- package/src/review/critic.mjs +2 -1
- package/src/toolwords.mjs +3 -0
- package/src/workspace.mjs +55 -10
- package/docs/decisions/README.md +0 -600
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Sealed export / import of the mined benchmark corpus (review 0003 Q48).
|
|
2
|
+
//
|
|
3
|
+
// The vault is keyed on the repository's root commit, outside every checkout, so
|
|
4
|
+
// a clone of the repository cannot read the hidden test patches the miner wrote:
|
|
5
|
+
// the committed corpus was unrunnable from any machine that was not the one that
|
|
6
|
+
// mined it. A sealed document carries the task descriptors and both patches for
|
|
7
|
+
// every task in a single file, so `bench export` on one machine and `bench import`
|
|
8
|
+
// on another reproduce the corpus exactly.
|
|
9
|
+
//
|
|
10
|
+
// The document is deterministic: no timestamp, tasks sorted by id, canonical
|
|
11
|
+
// (key-sorted) JSON, and a sha256 seal over everything but the seal itself. Two
|
|
12
|
+
// exports of the same corpus are byte-identical, and a reader can verify the
|
|
13
|
+
// seal before trusting a single byte of it.
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { chmod, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { dirname, join, resolve } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { STATE_DIR } from '../name.mjs';
|
|
20
|
+
import { packageVersion } from '../package-version.mjs';
|
|
21
|
+
import { resolveVaultDir } from './mine.mjs';
|
|
22
|
+
|
|
23
|
+
/** The document kind stamp, so a reader can refuse a file that is not a corpus. */
|
|
24
|
+
export const CORPUS_KIND = 'simt-bench-corpus';
|
|
25
|
+
|
|
26
|
+
/** The document schema version. Bump when the shape changes, not when content does. */
|
|
27
|
+
export const CORPUS_SCHEMA_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Canonical serialisation: object keys sorted, no whitespace. Arrays keep their
|
|
31
|
+
* order (the caller sorts them). This is what the seal hashes, so both the
|
|
32
|
+
* exporter and the importer must reduce the payload to exactly this form.
|
|
33
|
+
*
|
|
34
|
+
* @param {*} value a JSON value
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function canonical(value) {
|
|
38
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
39
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
40
|
+
const keys = Object.keys(value).sort();
|
|
41
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{tasks: object[]}} payload the document minus its seal
|
|
46
|
+
* @returns {string} hex sha256 of the canonical payload
|
|
47
|
+
*/
|
|
48
|
+
function sealOf(payload) {
|
|
49
|
+
return createHash('sha256').update(canonical(payload)).digest('hex');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function payloadOf(doc) {
|
|
53
|
+
const { seal, ...payload } = doc;
|
|
54
|
+
void seal;
|
|
55
|
+
return payload;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Read every mined task and its hidden patches from `root` and assemble the
|
|
60
|
+
* sealed corpus document (no file written; the command decides where it goes).
|
|
61
|
+
*
|
|
62
|
+
* @param {string} root
|
|
63
|
+
* @param {{env?: NodeJS.ProcessEnv}} [options]
|
|
64
|
+
* @returns {Promise<object>} the sealed document
|
|
65
|
+
*/
|
|
66
|
+
export async function buildCorpusDocument(root, { env = process.env } = {}) {
|
|
67
|
+
const absRoot = resolve(root);
|
|
68
|
+
const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
|
|
69
|
+
const vaultRoot = resolveVaultDir(absRoot, { env });
|
|
70
|
+
|
|
71
|
+
const names = (await readdir(tasksDir).catch(() => [])).filter((f) => f.endsWith('.json')).sort();
|
|
72
|
+
const tasks = [];
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
const task = JSON.parse(await readFile(join(tasksDir, name), 'utf8'));
|
|
75
|
+
const testPatch = await readFile(join(vaultRoot, task.id, 'test.patch'))
|
|
76
|
+
.then((b) => b.toString('base64'))
|
|
77
|
+
.catch(() => '');
|
|
78
|
+
const goldPatch = await readFile(join(vaultRoot, task.id, 'gold.patch'))
|
|
79
|
+
.then((b) => b.toString('base64'))
|
|
80
|
+
.catch(() => '');
|
|
81
|
+
tasks.push({ id: task.id, task, testPatch, goldPatch });
|
|
82
|
+
}
|
|
83
|
+
tasks.sort((a, b) => a.id.localeCompare(b.id));
|
|
84
|
+
|
|
85
|
+
const corpus = await readFile(join(dirname(tasksDir), 'corpus.json'), 'utf8')
|
|
86
|
+
.then((t) => JSON.parse(t))
|
|
87
|
+
.catch(() => null);
|
|
88
|
+
|
|
89
|
+
const payload = {
|
|
90
|
+
schemaVersion: CORPUS_SCHEMA_VERSION,
|
|
91
|
+
kind: CORPUS_KIND,
|
|
92
|
+
minedFrom: corpus?.minedFrom ?? null,
|
|
93
|
+
simtVersion: corpus?.simtVersion ?? packageVersion(),
|
|
94
|
+
taskCount: tasks.length,
|
|
95
|
+
tasks,
|
|
96
|
+
};
|
|
97
|
+
return { ...payload, seal: sealOf(payload) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Verify a corpus document's seal. Returns a reason string when the document is
|
|
102
|
+
* not a trusted corpus; `null` when it is.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} doc
|
|
105
|
+
* @returns {string|null}
|
|
106
|
+
*/
|
|
107
|
+
export function verifyCorpusDocument(doc) {
|
|
108
|
+
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) return 'not a corpus document';
|
|
109
|
+
if (doc.kind !== CORPUS_KIND) return `unexpected kind \`${doc.kind}\` (want \`${CORPUS_KIND}\`)`;
|
|
110
|
+
if (doc.schemaVersion !== CORPUS_SCHEMA_VERSION) {
|
|
111
|
+
return `unsupported schemaVersion \`${doc.schemaVersion}\` (want ${CORPUS_SCHEMA_VERSION})`;
|
|
112
|
+
}
|
|
113
|
+
const payload = payloadOf(doc);
|
|
114
|
+
if (sealOf(payload) !== doc.seal) return 'seal mismatch: the document was modified after it was sealed';
|
|
115
|
+
if (!Array.isArray(payload.tasks)) return 'missing task list';
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Write a verified corpus document into `root`: task descriptors to
|
|
121
|
+
* `.simt/bench/tasks/` and the hidden patches to the vault. Idempotent — a task
|
|
122
|
+
* already present is overwritten with the document's copy, which is the point of
|
|
123
|
+
* importing a known corpus.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} root
|
|
126
|
+
* @param {object} doc a document that passed `verifyCorpusDocument`
|
|
127
|
+
* @param {{env?: NodeJS.ProcessEnv, apply?: boolean}} [options]
|
|
128
|
+
* @returns {Promise<{applied: boolean, tasks: number, tasksDir: string, vaultRoot: string}>}
|
|
129
|
+
*/
|
|
130
|
+
export async function writeCorpusDocument(root, doc, { env = process.env, apply = true } = {}) {
|
|
131
|
+
const absRoot = resolve(root);
|
|
132
|
+
const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
|
|
133
|
+
const vaultRoot = resolveVaultDir(absRoot, { env });
|
|
134
|
+
|
|
135
|
+
if (apply) await mkdir(tasksDir, { recursive: true });
|
|
136
|
+
for (const entry of doc.tasks) {
|
|
137
|
+
if (apply) {
|
|
138
|
+
await writeFile(join(tasksDir, `${entry.id}.json`), `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
|
|
139
|
+
const taskVault = join(vaultRoot, entry.id);
|
|
140
|
+
// An imported corpus restores an answer key, so it gets the same 0700 the miner
|
|
141
|
+
// gives one (review 0003 Q49): the import must not be the cheaper way in.
|
|
142
|
+
await mkdir(taskVault, { recursive: true, mode: 0o700 });
|
|
143
|
+
await chmod(taskVault, 0o700).catch(() => {});
|
|
144
|
+
await writeFile(join(taskVault, 'test.patch'), Buffer.from(entry.testPatch, 'base64'));
|
|
145
|
+
await writeFile(join(taskVault, 'gold.patch'), Buffer.from(entry.goldPatch, 'base64'));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { applied: apply, tasks: doc.tasks.length, tasksDir, vaultRoot };
|
|
150
|
+
}
|
package/src/bench/grade.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
2
|
import { readFile, rm } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
|
|
5
5
|
import { runGates } from '../hooks/dispatcher.mjs';
|
|
6
6
|
import { packageVersion } from '../package-version.mjs';
|
|
7
7
|
import { readManifest } from '../projection/manifest.mjs';
|
|
8
|
-
import { detectTestRunner } from './mine.mjs';
|
|
8
|
+
import { detectTestRunner, readReports } from './mine.mjs';
|
|
9
|
+
import { spawnBounded } from './process.mjs';
|
|
9
10
|
|
|
10
11
|
const DEFAULT_GRADE_TIMEOUT_MS = 45_000;
|
|
11
12
|
|
|
@@ -17,48 +18,8 @@ function sanitizedEnv(extra = {}) {
|
|
|
17
18
|
return env;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
function runCmd(cmd, args, { cwd, input = null, timeoutMs = DEFAULT_GRADE_TIMEOUT_MS } = {}) {
|
|
21
|
-
return
|
|
22
|
-
const child = spawn(cmd, args, {
|
|
23
|
-
cwd,
|
|
24
|
-
env: sanitizedEnv(),
|
|
25
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
26
|
-
});
|
|
27
|
-
const out = [];
|
|
28
|
-
const err = [];
|
|
29
|
-
let timedOut = false;
|
|
30
|
-
const timer =
|
|
31
|
-
timeoutMs > 0
|
|
32
|
-
? setTimeout(() => {
|
|
33
|
-
timedOut = true;
|
|
34
|
-
child.kill('SIGKILL');
|
|
35
|
-
}, timeoutMs)
|
|
36
|
-
: null;
|
|
37
|
-
|
|
38
|
-
child.stdout.on('data', (chunk) => out.push(chunk));
|
|
39
|
-
child.stderr.on('data', (chunk) => err.push(chunk));
|
|
40
|
-
child.on('error', (error) => {
|
|
41
|
-
if (timer) clearTimeout(timer);
|
|
42
|
-
resolveOutcome({
|
|
43
|
-
code: 1,
|
|
44
|
-
timedOut,
|
|
45
|
-
stdout: Buffer.concat(out),
|
|
46
|
-
stderr: Buffer.from(String(error.message ?? error)),
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
child.on('close', (code) => {
|
|
50
|
-
if (timer) clearTimeout(timer);
|
|
51
|
-
resolveOutcome({
|
|
52
|
-
code: timedOut ? 124 : (code ?? 1),
|
|
53
|
-
timedOut,
|
|
54
|
-
stdout: Buffer.concat(out),
|
|
55
|
-
stderr: Buffer.concat(err),
|
|
56
|
-
});
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
if (input !== null) child.stdin.end(input);
|
|
60
|
-
else child.stdin.end();
|
|
61
|
-
});
|
|
21
|
+
function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_GRADE_TIMEOUT_MS } = {}) {
|
|
22
|
+
return spawnBounded(cmd, args, { cwd, env: env ?? sanitizedEnv(), input, timeoutMs });
|
|
62
23
|
}
|
|
63
24
|
|
|
64
25
|
/**
|
|
@@ -136,8 +97,10 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
|
|
|
136
97
|
readFile(join(taskVaultDir, 'gold.patch')).catch(() => Buffer.alloc(0)),
|
|
137
98
|
]);
|
|
138
99
|
|
|
139
|
-
//
|
|
140
|
-
//
|
|
100
|
+
// Intent-to-add every untracked file so a file an agent created is part of the diff:
|
|
101
|
+
// `git diff` reports tracked changes only and would not count a new file at all.
|
|
102
|
+
// Nothing is removed — an arm's untracked file is an edit outside the blast radius,
|
|
103
|
+
// which is what it is.
|
|
141
104
|
await runCmd('git', ['add', '-N', '.'], { cwd: workDir });
|
|
142
105
|
const [diffNamesRes, diffPatchRes] = await Promise.all([
|
|
143
106
|
runCmd('git', ['diff', '--name-only', initialHead], { cwd: workDir }),
|
|
@@ -173,6 +136,7 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
|
|
|
173
136
|
resolved: false,
|
|
174
137
|
gatesPassed: false,
|
|
175
138
|
diff,
|
|
139
|
+
f2pPassed: [],
|
|
176
140
|
goldFiles: [...goldFiles],
|
|
177
141
|
agentPatch: diffPatchRes.stdout,
|
|
178
142
|
};
|
|
@@ -184,16 +148,23 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
|
|
|
184
148
|
resolved: false,
|
|
185
149
|
gatesPassed: false,
|
|
186
150
|
diff,
|
|
151
|
+
f2pPassed: [],
|
|
187
152
|
goldFiles: [...goldFiles],
|
|
188
153
|
agentPatch: diffPatchRes.stdout,
|
|
189
154
|
};
|
|
190
155
|
}
|
|
191
156
|
|
|
192
157
|
const execSpec = runner.buildExec(workDir);
|
|
158
|
+
// Same stale-report hazard as the miner: a report left by a prior run in
|
|
159
|
+
// this workspace would be graded as this agent's result (review 0003 Q48).
|
|
160
|
+
for (const p of execSpec.reportPaths ?? []) {
|
|
161
|
+
if (existsSync(p)) await rm(p, { recursive: true, force: true });
|
|
162
|
+
}
|
|
193
163
|
const runRes = await runCmd(execSpec.cmd, execSpec.args, { cwd: workDir, timeoutMs });
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
164
|
+
// readReports walks the report *directories* Gradle and Maven write (one
|
|
165
|
+
// XML per test class); the old single-file read saw none of them, so the
|
|
166
|
+
// grader resolved on an empty parse.
|
|
167
|
+
const reportText = await readReports(execSpec.reportPaths);
|
|
197
168
|
const parsed = runner.parse(runRes.stdout.toString('utf8'), reportText);
|
|
198
169
|
const testRun = {
|
|
199
170
|
code: runRes.code,
|
|
@@ -226,6 +197,9 @@ export async function gradeWorkspace(workDir, task, { vaultRoot, initialHead, ti
|
|
|
226
197
|
resolved,
|
|
227
198
|
gatesPassed,
|
|
228
199
|
diff,
|
|
200
|
+
// Which fail-to-pass tests actually passed. `resolved` is a boolean, so a regrade
|
|
201
|
+
// could only compare the verdict; the set is what a reader can check (review Q59).
|
|
202
|
+
f2pPassed: Array.isArray(task.f2p) ? task.f2p.filter((id) => passedSet.has(id)) : [],
|
|
229
203
|
goldFiles: [...goldFiles],
|
|
230
204
|
agentPatch: diffPatchRes.stdout,
|
|
231
205
|
};
|
package/src/bench/mine.mjs
CHANGED
|
@@ -10,22 +10,30 @@
|
|
|
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, spawn } from 'node:child_process';
|
|
13
|
+
import { execFile, execFileSync, spawn } from 'node:child_process';
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { existsSync } from 'node:fs';
|
|
16
|
-
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { homedir, platform as osPlatform, 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 } from '../name.mjs';
|
|
21
|
+
import { CONFIG_DIR, STATE_DIR } from '../name.mjs';
|
|
22
22
|
import { SCHEMA_VERSION } from '../output/index.mjs';
|
|
23
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
24
|
+
import { packageVersion } from '../package-version.mjs';
|
|
23
25
|
|
|
24
26
|
const exec = promisify(execFile);
|
|
25
27
|
|
|
26
28
|
/** Default target task count when mining a repository's history. */
|
|
27
29
|
export const DEFAULT_MINE_TARGET = 20;
|
|
28
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Test paths excluded by default, overridable via `simt.json` `bench.mine.exclude`
|
|
33
|
+
* (review 0003 Q48: the pack integration test was hardcoded here, a repo fact, not a miner fact).
|
|
34
|
+
*/
|
|
35
|
+
const DEFAULT_EXCLUDES = ['test/pack.test.mjs'];
|
|
36
|
+
|
|
29
37
|
/**
|
|
30
38
|
* Classify whether a repository-relative path is a test file.
|
|
31
39
|
*
|
|
@@ -285,13 +293,56 @@ export function parseCargoTestOutput(text) {
|
|
|
285
293
|
};
|
|
286
294
|
}
|
|
287
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
|
+
|
|
288
339
|
/**
|
|
289
340
|
* Detect the test runner for a repository and build the command for a set of
|
|
290
341
|
* changed test files.
|
|
291
342
|
*
|
|
292
343
|
* @param {string} root
|
|
293
344
|
* @param {string[]} testFiles
|
|
294
|
-
* @returns {Promise<{runner: string, displayCmd: string, buildExec: (worktree: string) => {cmd: string, args: string[],
|
|
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>}
|
|
295
346
|
*/
|
|
296
347
|
export async function detectTestRunner(root, testFiles) {
|
|
297
348
|
const files = [...testFiles].sort();
|
|
@@ -317,6 +368,22 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
317
368
|
parse: (stdout) => parseVitestJson(stdout),
|
|
318
369
|
};
|
|
319
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
|
+
}
|
|
320
387
|
return {
|
|
321
388
|
runner: 'node-test',
|
|
322
389
|
displayCmd: `node --test ${files.join(' ')}`,
|
|
@@ -380,7 +447,7 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
380
447
|
return {
|
|
381
448
|
cmd: 'pytest',
|
|
382
449
|
args: [`--junitxml=${reportPath}`, ...files],
|
|
383
|
-
reportPath,
|
|
450
|
+
reportPaths: [reportPath],
|
|
384
451
|
};
|
|
385
452
|
},
|
|
386
453
|
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
@@ -398,7 +465,8 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
398
465
|
buildExec: (wt) => ({
|
|
399
466
|
cmd: existsSync(join(wt, 'gradlew')) ? join(wt, 'gradlew') : 'gradle',
|
|
400
467
|
args: ['test', ...filterArgs],
|
|
401
|
-
|
|
468
|
+
// One XML per test class; readReports walks the directory.
|
|
469
|
+
reportPaths: [join(wt, 'build', 'test-results', 'test')],
|
|
402
470
|
}),
|
|
403
471
|
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
404
472
|
};
|
|
@@ -412,9 +480,11 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
412
480
|
return {
|
|
413
481
|
runner: 'maven',
|
|
414
482
|
displayCmd: `mvn test ${arg.join(' ')}`.trim(),
|
|
415
|
-
buildExec: () => ({
|
|
483
|
+
buildExec: (wt) => ({
|
|
416
484
|
cmd: 'mvn',
|
|
417
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')],
|
|
418
488
|
}),
|
|
419
489
|
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
420
490
|
};
|
|
@@ -448,9 +518,32 @@ export function resolveVaultDir(root, { env = process.env, home = homedir(), pla
|
|
|
448
518
|
return join(cacheBase, CONFIG_DIR, 'vault', repoId);
|
|
449
519
|
}
|
|
450
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
|
+
*/
|
|
451
534
|
function repoIdentifier(root) {
|
|
452
535
|
const full = resolve(root);
|
|
453
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
|
+
}
|
|
454
547
|
const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
|
|
455
548
|
return `${name}-${digest}`;
|
|
456
549
|
}
|
|
@@ -519,7 +612,11 @@ export async function listCandidates(root) {
|
|
|
519
612
|
|
|
520
613
|
const cleanSubject = (subject ?? '').trim();
|
|
521
614
|
const cleanBody = (body ?? '').trim();
|
|
522
|
-
|
|
615
|
+
// Subject only by default. A commit body usually narrates the fix ("use x * x * x
|
|
616
|
+
// here"), which hands the agent the answer and measures nothing; a repository whose
|
|
617
|
+
// messages are curated can ask for the whole message with `bench.mine.prompt: "body"`
|
|
618
|
+
// (review 0003 Q49).
|
|
619
|
+
const prompt = cleanSubject;
|
|
523
620
|
|
|
524
621
|
candidates.push({
|
|
525
622
|
sha: sha.trim(),
|
|
@@ -584,14 +681,14 @@ function runProcess(cmd, args, { cwd, input, env, timeoutMs = 15000 } = {}) {
|
|
|
584
681
|
|
|
585
682
|
async function executeRunner(runner, worktree, env, timeoutMs) {
|
|
586
683
|
const spec = runner.buildExec(worktree);
|
|
587
|
-
|
|
588
|
-
|
|
684
|
+
// Remove what a previous run left behind before running: a stale report
|
|
685
|
+
// would parse as this run's result, and a directory report accumulates
|
|
686
|
+
// one XML per class across runs.
|
|
687
|
+
for (const p of spec.reportPaths ?? []) {
|
|
688
|
+
if (existsSync(p)) await rm(p, { recursive: true, force: true });
|
|
589
689
|
}
|
|
590
690
|
const res = await runProcess(spec.cmd, spec.args, { cwd: worktree, env, timeoutMs });
|
|
591
|
-
|
|
592
|
-
if (spec.reportPath && existsSync(spec.reportPath)) {
|
|
593
|
-
reportText = await readFile(spec.reportPath, 'utf8').catch(() => '');
|
|
594
|
-
}
|
|
691
|
+
const reportText = await readReports(spec.reportPaths);
|
|
595
692
|
const parsed = runner.parse(res.stdout, reportText);
|
|
596
693
|
return {
|
|
597
694
|
code: res.code,
|
|
@@ -606,10 +703,20 @@ async function executeRunner(runner, worktree, env, timeoutMs) {
|
|
|
606
703
|
* twice, dropping any candidate whose test outcomes are not identical across both
|
|
607
704
|
* passes.
|
|
608
705
|
*/
|
|
609
|
-
async function validateCandidate(
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
706
|
+
async function validateCandidate(
|
|
707
|
+
root,
|
|
708
|
+
worktree,
|
|
709
|
+
candidate,
|
|
710
|
+
{ timeoutMs = 5000, exclude = DEFAULT_EXCLUDES, promptMode = 'subject' } = {},
|
|
711
|
+
) {
|
|
712
|
+
// Skip commits that modify more than 4 test files, or that touch a path
|
|
713
|
+
// the repository's manifest excludes (`simt.json` bench.mine.exclude,
|
|
714
|
+
// defaulting to the pack integration test the committed corpus was mined
|
|
715
|
+
// around): sweeping renames rather than self-contained benchmark tasks.
|
|
716
|
+
if (
|
|
717
|
+
candidate.testFiles.length > 4 ||
|
|
718
|
+
candidate.testFiles.some((f) => exclude.includes(f))
|
|
719
|
+
) {
|
|
613
720
|
return null;
|
|
614
721
|
}
|
|
615
722
|
|
|
@@ -682,8 +789,10 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
|
|
|
682
789
|
const p2p = gold1.passed.filter((id) => redPassedSet.has(id) && !redFailedSet.has(id));
|
|
683
790
|
if (f2p.length === 0) return null;
|
|
684
791
|
|
|
685
|
-
|
|
686
|
-
|
|
792
|
+
// 12 hex chars of the source commit: the 7-char form plus a fixed `-01`
|
|
793
|
+
// suffix could not disambiguate two tasks from the same commit (review 0003
|
|
794
|
+
// Q48). Collisions are checked at write time, where the corpus is visible.
|
|
795
|
+
const id = `t-${candidate.sha.slice(0, 12)}-01`;
|
|
687
796
|
const gold = patchStats(goldPatch, candidate.implFiles.length);
|
|
688
797
|
|
|
689
798
|
return {
|
|
@@ -692,7 +801,11 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
|
|
|
692
801
|
id,
|
|
693
802
|
base: candidate.base,
|
|
694
803
|
sourceCommit: candidate.sha,
|
|
695
|
-
prompt:
|
|
804
|
+
prompt:
|
|
805
|
+
promptMode === 'body' && candidate.body
|
|
806
|
+
? `${candidate.subject}\n\n${candidate.body}`
|
|
807
|
+
: candidate.subject,
|
|
808
|
+
promptBasis: promptMode === 'body' && candidate.body ? 'body' : 'subject',
|
|
696
809
|
testCmd: runner.displayCmd,
|
|
697
810
|
f2p,
|
|
698
811
|
p2p,
|
|
@@ -713,8 +826,12 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
|
|
|
713
826
|
* temporary worktrees until `target` valid tasks are collected, and written
|
|
714
827
|
* deterministically to disk.
|
|
715
828
|
*
|
|
829
|
+
* Writing is append-only: existing task files are left alone unless `replace`
|
|
830
|
+
* is set, which restores the old "reflect exactly this history" semantics by
|
|
831
|
+
* deleting files the current history does not reproduce (review 0003 Q48).
|
|
832
|
+
*
|
|
716
833
|
* @param {string} root
|
|
717
|
-
* @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
|
|
834
|
+
* @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, replace?: boolean, env?: NodeJS.ProcessEnv}} [options]
|
|
718
835
|
*/
|
|
719
836
|
export async function mineTasks(
|
|
720
837
|
root,
|
|
@@ -723,6 +840,7 @@ export async function mineTasks(
|
|
|
723
840
|
target = DEFAULT_MINE_TARGET,
|
|
724
841
|
concurrency = 4,
|
|
725
842
|
timeoutMs = 5000,
|
|
843
|
+
replace = false,
|
|
726
844
|
env = process.env,
|
|
727
845
|
} = {},
|
|
728
846
|
) {
|
|
@@ -732,12 +850,20 @@ export async function mineTasks(
|
|
|
732
850
|
return { ok: false, error: listed.error };
|
|
733
851
|
}
|
|
734
852
|
|
|
735
|
-
|
|
853
|
+
// Repository-declared mining config (review 0003 Q48): an explicit option
|
|
854
|
+
// wins, then the manifest, then the default.
|
|
855
|
+
const { manifest } = await readManifest(absRoot);
|
|
856
|
+
const benchCfg = manifest?.bench?.mine ?? {};
|
|
857
|
+
const effectiveTimeout = benchCfg.timeoutMs ?? timeoutMs;
|
|
858
|
+
const exclude = benchCfg.exclude ?? DEFAULT_EXCLUDES;
|
|
859
|
+
const promptMode = benchCfg.prompt === 'body' ? 'body' : 'subject';
|
|
860
|
+
|
|
861
|
+
const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
|
|
736
862
|
const vaultRoot = resolveVaultDir(absRoot, { env });
|
|
737
863
|
|
|
738
864
|
if (!apply) {
|
|
739
865
|
const preview = listed.candidates.slice(0, target).map((c) => ({
|
|
740
|
-
id: `t-${c.sha.slice(0,
|
|
866
|
+
id: `t-${c.sha.slice(0, 12)}-01`,
|
|
741
867
|
base: c.base,
|
|
742
868
|
sourceCommit: c.sha,
|
|
743
869
|
subject: c.subject,
|
|
@@ -790,7 +916,11 @@ export async function mineTasks(
|
|
|
790
916
|
cursor += 1;
|
|
791
917
|
if (index >= listed.candidates.length) break;
|
|
792
918
|
const candidate = listed.candidates[index];
|
|
793
|
-
const res = await validateCandidate(absRoot, wt, candidate, {
|
|
919
|
+
const res = await validateCandidate(absRoot, wt, candidate, {
|
|
920
|
+
timeoutMs: effectiveTimeout,
|
|
921
|
+
exclude,
|
|
922
|
+
promptMode,
|
|
923
|
+
});
|
|
794
924
|
if (res) {
|
|
795
925
|
validated.push({ index, ...res });
|
|
796
926
|
}
|
|
@@ -805,28 +935,62 @@ export async function mineTasks(
|
|
|
805
935
|
const selected = validated.slice(0, target);
|
|
806
936
|
|
|
807
937
|
await mkdir(tasksDir, { recursive: true });
|
|
808
|
-
await mkdir(vaultRoot, { recursive: true });
|
|
809
|
-
|
|
810
|
-
//
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
938
|
+
await mkdir(vaultRoot, { recursive: true, mode: 0o700 });
|
|
939
|
+
// The hidden tests are the answer key: 0700 keeps every other local account out.
|
|
940
|
+
// `mkdir` alone does not tighten a vault an earlier run created.
|
|
941
|
+
await chmod(vaultRoot, 0o700).catch(() => {});
|
|
942
|
+
|
|
943
|
+
// Mining is append-only (review 0003 Q48: `mineTasks(target 3)` once
|
|
944
|
+
// deleted all 20 committed tasks and wrote 3 others). `replace` restores
|
|
945
|
+
// the old "reflect exactly this history" semantics, and only it deletes.
|
|
946
|
+
if (replace) {
|
|
947
|
+
const existing = await readdir(tasksDir).catch(() => []);
|
|
948
|
+
const keepNames = new Set(selected.map((entry) => `${entry.task.id}.json`));
|
|
949
|
+
for (const name of existing) {
|
|
950
|
+
if (name.endsWith('.json') && !keepNames.has(name)) {
|
|
951
|
+
await rm(join(tasksDir, name), { force: true });
|
|
952
|
+
}
|
|
817
953
|
}
|
|
818
954
|
}
|
|
819
955
|
|
|
820
956
|
for (const entry of selected) {
|
|
821
957
|
const taskPath = join(tasksDir, `${entry.task.id}.json`);
|
|
958
|
+
// An id belongs to exactly one source commit. If a file with this id
|
|
959
|
+
// holds a different commit, the 12-char prefix collided and guessing
|
|
960
|
+
// which corpus the reader wants is worse than refusing.
|
|
961
|
+
if (existsSync(taskPath) && !replace) {
|
|
962
|
+
const prior = JSON.parse(await readFile(taskPath, 'utf8'));
|
|
963
|
+
if (prior.sourceCommit !== entry.task.sourceCommit) {
|
|
964
|
+
return {
|
|
965
|
+
ok: false,
|
|
966
|
+
applied: false,
|
|
967
|
+
error: `id collision: ${taskPath} holds task from ${prior.sourceCommit}, mine produced ${entry.task.sourceCommit}; rerun with --replace to take over the corpus`,
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
}
|
|
822
971
|
await writeFile(taskPath, `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
|
|
823
972
|
|
|
824
973
|
const taskVault = join(vaultRoot, entry.task.id);
|
|
825
|
-
await mkdir(taskVault, { recursive: true });
|
|
974
|
+
await mkdir(taskVault, { recursive: true, mode: 0o700 });
|
|
975
|
+
await chmod(taskVault, 0o700).catch(() => {});
|
|
826
976
|
await writeFile(join(taskVault, 'test.patch'), entry.testPatch);
|
|
827
977
|
await writeFile(join(taskVault, 'gold.patch'), entry.goldPatch);
|
|
828
978
|
}
|
|
829
979
|
|
|
980
|
+
// The corpus records where it came from: which commit was HEAD when the
|
|
981
|
+
// mine ran, the target, and the simt version that validated the tasks.
|
|
982
|
+
// No timestamp — a re-mine at the same HEAD is byte-identical.
|
|
983
|
+
const { stdout: headSha } = await exec('git', ['rev-parse', 'HEAD'], { cwd: absRoot });
|
|
984
|
+
await writeFile(
|
|
985
|
+
join(dirname(tasksDir), 'corpus.json'),
|
|
986
|
+
`${JSON.stringify(
|
|
987
|
+
{ minedFrom: headSha.trim(), target, simtVersion: packageVersion() },
|
|
988
|
+
null,
|
|
989
|
+
2,
|
|
990
|
+
)}\n`,
|
|
991
|
+
'utf8',
|
|
992
|
+
);
|
|
993
|
+
|
|
830
994
|
return {
|
|
831
995
|
ok: true,
|
|
832
996
|
applied: true,
|