simt-dev 0.6.3 → 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 +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.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-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- 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 +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- 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 +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- 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 +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- 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 +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- 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/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
package/src/bench/mine.mjs
CHANGED
|
@@ -10,30 +10,39 @@
|
|
|
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
|
*
|
|
32
|
-
* Covers the
|
|
40
|
+
* Covers the ecosystems named in plan 0003 §5 R01:
|
|
33
41
|
* - `test/`, `tests/`, `spec/`, `__tests__/`, `src/test/`
|
|
34
42
|
* - `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`
|
|
35
43
|
* - `*_test.go` (Go)
|
|
36
44
|
* - `test_*.py` (pytest)
|
|
45
|
+
* - `tests/*.rs` (Cargo integration tests)
|
|
37
46
|
* - `*Test.java`, `*Tests.java`, `*Test.kt`, `*Tests.kt` (Gradle / Maven)
|
|
38
47
|
*
|
|
39
48
|
* @param {string} relPath forward-slash repository-relative path
|
|
@@ -259,13 +268,81 @@ export function parseGoTestJson(text) {
|
|
|
259
268
|
};
|
|
260
269
|
}
|
|
261
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
|
+
|
|
262
339
|
/**
|
|
263
340
|
* Detect the test runner for a repository and build the command for a set of
|
|
264
341
|
* changed test files.
|
|
265
342
|
*
|
|
266
343
|
* @param {string} root
|
|
267
344
|
* @param {string[]} testFiles
|
|
268
|
-
* @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>}
|
|
269
346
|
*/
|
|
270
347
|
export async function detectTestRunner(root, testFiles) {
|
|
271
348
|
const files = [...testFiles].sort();
|
|
@@ -291,6 +368,22 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
291
368
|
parse: (stdout) => parseVitestJson(stdout),
|
|
292
369
|
};
|
|
293
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
|
+
}
|
|
294
387
|
return {
|
|
295
388
|
runner: 'node-test',
|
|
296
389
|
displayCmd: `node --test ${files.join(' ')}`,
|
|
@@ -302,6 +395,27 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
302
395
|
};
|
|
303
396
|
}
|
|
304
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
|
+
|
|
305
419
|
if (existsSync(join(root, 'go.mod'))) {
|
|
306
420
|
const pkgs = dedupeSorted(
|
|
307
421
|
files.map((f) => {
|
|
@@ -333,7 +447,7 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
333
447
|
return {
|
|
334
448
|
cmd: 'pytest',
|
|
335
449
|
args: [`--junitxml=${reportPath}`, ...files],
|
|
336
|
-
reportPath,
|
|
450
|
+
reportPaths: [reportPath],
|
|
337
451
|
};
|
|
338
452
|
},
|
|
339
453
|
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
@@ -351,7 +465,8 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
351
465
|
buildExec: (wt) => ({
|
|
352
466
|
cmd: existsSync(join(wt, 'gradlew')) ? join(wt, 'gradlew') : 'gradle',
|
|
353
467
|
args: ['test', ...filterArgs],
|
|
354
|
-
|
|
468
|
+
// One XML per test class; readReports walks the directory.
|
|
469
|
+
reportPaths: [join(wt, 'build', 'test-results', 'test')],
|
|
355
470
|
}),
|
|
356
471
|
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
357
472
|
};
|
|
@@ -365,9 +480,11 @@ export async function detectTestRunner(root, testFiles) {
|
|
|
365
480
|
return {
|
|
366
481
|
runner: 'maven',
|
|
367
482
|
displayCmd: `mvn test ${arg.join(' ')}`.trim(),
|
|
368
|
-
buildExec: () => ({
|
|
483
|
+
buildExec: (wt) => ({
|
|
369
484
|
cmd: 'mvn',
|
|
370
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')],
|
|
371
488
|
}),
|
|
372
489
|
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
373
490
|
};
|
|
@@ -401,9 +518,32 @@ export function resolveVaultDir(root, { env = process.env, home = homedir(), pla
|
|
|
401
518
|
return join(cacheBase, CONFIG_DIR, 'vault', repoId);
|
|
402
519
|
}
|
|
403
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
|
+
*/
|
|
404
534
|
function repoIdentifier(root) {
|
|
405
535
|
const full = resolve(root);
|
|
406
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
|
+
}
|
|
407
547
|
const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
|
|
408
548
|
return `${name}-${digest}`;
|
|
409
549
|
}
|
|
@@ -472,7 +612,11 @@ export async function listCandidates(root) {
|
|
|
472
612
|
|
|
473
613
|
const cleanSubject = (subject ?? '').trim();
|
|
474
614
|
const cleanBody = (body ?? '').trim();
|
|
475
|
-
|
|
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;
|
|
476
620
|
|
|
477
621
|
candidates.push({
|
|
478
622
|
sha: sha.trim(),
|
|
@@ -537,14 +681,14 @@ function runProcess(cmd, args, { cwd, input, env, timeoutMs = 15000 } = {}) {
|
|
|
537
681
|
|
|
538
682
|
async function executeRunner(runner, worktree, env, timeoutMs) {
|
|
539
683
|
const spec = runner.buildExec(worktree);
|
|
540
|
-
|
|
541
|
-
|
|
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 });
|
|
542
689
|
}
|
|
543
690
|
const res = await runProcess(spec.cmd, spec.args, { cwd: worktree, env, timeoutMs });
|
|
544
|
-
|
|
545
|
-
if (spec.reportPath && existsSync(spec.reportPath)) {
|
|
546
|
-
reportText = await readFile(spec.reportPath, 'utf8').catch(() => '');
|
|
547
|
-
}
|
|
691
|
+
const reportText = await readReports(spec.reportPaths);
|
|
548
692
|
const parsed = runner.parse(res.stdout, reportText);
|
|
549
693
|
return {
|
|
550
694
|
code: res.code,
|
|
@@ -559,10 +703,20 @@ async function executeRunner(runner, worktree, env, timeoutMs) {
|
|
|
559
703
|
* twice, dropping any candidate whose test outcomes are not identical across both
|
|
560
704
|
* passes.
|
|
561
705
|
*/
|
|
562
|
-
async function validateCandidate(
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
) {
|
|
566
720
|
return null;
|
|
567
721
|
}
|
|
568
722
|
|
|
@@ -635,8 +789,10 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
|
|
|
635
789
|
const p2p = gold1.passed.filter((id) => redPassedSet.has(id) && !redFailedSet.has(id));
|
|
636
790
|
if (f2p.length === 0) return null;
|
|
637
791
|
|
|
638
|
-
|
|
639
|
-
|
|
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`;
|
|
640
796
|
const gold = patchStats(goldPatch, candidate.implFiles.length);
|
|
641
797
|
|
|
642
798
|
return {
|
|
@@ -645,7 +801,11 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
|
|
|
645
801
|
id,
|
|
646
802
|
base: candidate.base,
|
|
647
803
|
sourceCommit: candidate.sha,
|
|
648
|
-
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',
|
|
649
809
|
testCmd: runner.displayCmd,
|
|
650
810
|
f2p,
|
|
651
811
|
p2p,
|
|
@@ -666,8 +826,12 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
|
|
|
666
826
|
* temporary worktrees until `target` valid tasks are collected, and written
|
|
667
827
|
* deterministically to disk.
|
|
668
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
|
+
*
|
|
669
833
|
* @param {string} root
|
|
670
|
-
* @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]
|
|
671
835
|
*/
|
|
672
836
|
export async function mineTasks(
|
|
673
837
|
root,
|
|
@@ -676,6 +840,7 @@ export async function mineTasks(
|
|
|
676
840
|
target = DEFAULT_MINE_TARGET,
|
|
677
841
|
concurrency = 4,
|
|
678
842
|
timeoutMs = 5000,
|
|
843
|
+
replace = false,
|
|
679
844
|
env = process.env,
|
|
680
845
|
} = {},
|
|
681
846
|
) {
|
|
@@ -685,12 +850,20 @@ export async function mineTasks(
|
|
|
685
850
|
return { ok: false, error: listed.error };
|
|
686
851
|
}
|
|
687
852
|
|
|
688
|
-
|
|
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');
|
|
689
862
|
const vaultRoot = resolveVaultDir(absRoot, { env });
|
|
690
863
|
|
|
691
864
|
if (!apply) {
|
|
692
865
|
const preview = listed.candidates.slice(0, target).map((c) => ({
|
|
693
|
-
id: `t-${c.sha.slice(0,
|
|
866
|
+
id: `t-${c.sha.slice(0, 12)}-01`,
|
|
694
867
|
base: c.base,
|
|
695
868
|
sourceCommit: c.sha,
|
|
696
869
|
subject: c.subject,
|
|
@@ -743,7 +916,11 @@ export async function mineTasks(
|
|
|
743
916
|
cursor += 1;
|
|
744
917
|
if (index >= listed.candidates.length) break;
|
|
745
918
|
const candidate = listed.candidates[index];
|
|
746
|
-
const res = await validateCandidate(absRoot, wt, candidate, {
|
|
919
|
+
const res = await validateCandidate(absRoot, wt, candidate, {
|
|
920
|
+
timeoutMs: effectiveTimeout,
|
|
921
|
+
exclude,
|
|
922
|
+
promptMode,
|
|
923
|
+
});
|
|
747
924
|
if (res) {
|
|
748
925
|
validated.push({ index, ...res });
|
|
749
926
|
}
|
|
@@ -758,28 +935,62 @@ export async function mineTasks(
|
|
|
758
935
|
const selected = validated.slice(0, target);
|
|
759
936
|
|
|
760
937
|
await mkdir(tasksDir, { recursive: true });
|
|
761
|
-
await mkdir(vaultRoot, { recursive: true });
|
|
762
|
-
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
+
}
|
|
770
953
|
}
|
|
771
954
|
}
|
|
772
955
|
|
|
773
956
|
for (const entry of selected) {
|
|
774
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
|
+
}
|
|
775
971
|
await writeFile(taskPath, `${JSON.stringify(entry.task, null, 2)}\n`, 'utf8');
|
|
776
972
|
|
|
777
973
|
const taskVault = join(vaultRoot, entry.task.id);
|
|
778
|
-
await mkdir(taskVault, { recursive: true });
|
|
974
|
+
await mkdir(taskVault, { recursive: true, mode: 0o700 });
|
|
975
|
+
await chmod(taskVault, 0o700).catch(() => {});
|
|
779
976
|
await writeFile(join(taskVault, 'test.patch'), entry.testPatch);
|
|
780
977
|
await writeFile(join(taskVault, 'gold.patch'), entry.goldPatch);
|
|
781
978
|
}
|
|
782
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
|
+
|
|
783
994
|
return {
|
|
784
995
|
ok: true,
|
|
785
996
|
applied: true,
|