simt-dev 0.7.0 → 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 +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- 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-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/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 +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- 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/docs.mjs +88 -0
- 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 +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- 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 +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- 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 +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- 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 +94 -20
- 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 +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- 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/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- 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/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- 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 +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- 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/sys/proc.mjs +151 -0
- 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 +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- 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/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
package/src/bench/mine.mjs
CHANGED
|
@@ -13,455 +13,28 @@
|
|
|
13
13
|
import { execFile, 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';
|
|
17
|
-
import {
|
|
16
|
+
import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
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 {
|
|
21
|
+
import { STATE_DIR } from '../name.mjs';
|
|
22
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
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';
|
|
23
29
|
|
|
24
30
|
const exec = promisify(execFile);
|
|
25
|
-
|
|
26
|
-
/** Default target task count when mining a repository's history. */
|
|
27
31
|
export const DEFAULT_MINE_TARGET = 20;
|
|
28
32
|
|
|
29
33
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* Covers the ecosystems named in plan 0003 §5 R01:
|
|
33
|
-
* - `test/`, `tests/`, `spec/`, `__tests__/`, `src/test/`
|
|
34
|
-
* - `*.test.*`, `*.spec.*`, `*_test.*`, `*_spec.*`
|
|
35
|
-
* - `*_test.go` (Go)
|
|
36
|
-
* - `test_*.py` (pytest)
|
|
37
|
-
* - `tests/*.rs` (Cargo integration tests)
|
|
38
|
-
* - `*Test.java`, `*Tests.java`, `*Test.kt`, `*Tests.kt` (Gradle / Maven)
|
|
39
|
-
*
|
|
40
|
-
* @param {string} relPath forward-slash repository-relative path
|
|
41
|
-
* @returns {boolean}
|
|
42
|
-
*/
|
|
43
|
-
export function isTestPath(relPath) {
|
|
44
|
-
const norm = String(relPath).replaceAll('\\', '/').replace(/^\.\//, '');
|
|
45
|
-
if (!norm) return false;
|
|
46
|
-
const segments = norm.split('/');
|
|
47
|
-
const base = segments[segments.length - 1];
|
|
48
|
-
|
|
49
|
-
if (
|
|
50
|
-
norm.startsWith('test/') ||
|
|
51
|
-
norm.startsWith('tests/') ||
|
|
52
|
-
norm.startsWith('spec/') ||
|
|
53
|
-
norm.startsWith('__tests__/') ||
|
|
54
|
-
norm.startsWith('src/test/') ||
|
|
55
|
-
segments.includes('__tests__') ||
|
|
56
|
-
segments.includes('test') ||
|
|
57
|
-
segments.includes('tests') ||
|
|
58
|
-
segments.includes('spec')
|
|
59
|
-
) {
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (/\.(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) return true;
|
|
64
|
-
if (/_(?:test|spec)\.[A-Za-z0-9]+$/.test(base)) return true;
|
|
65
|
-
if (/^test_[^/]+\.py$/.test(base)) return true;
|
|
66
|
-
if (/Tests?\.(?:java|kt)$/.test(base)) return true;
|
|
67
|
-
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Parse TAP version 13 output from `node --test --test-reporter=tap`.
|
|
73
|
-
*
|
|
74
|
-
* Leaf test cases carry `type: 'test'` in their YAML block; suite headers carry
|
|
75
|
-
* `type: 'suite'` and are used only to build `parent > child` qualified IDs.
|
|
76
|
-
*
|
|
77
|
-
* @param {string} text
|
|
78
|
-
* @returns {{passed: string[], failed: string[]}}
|
|
79
|
-
*/
|
|
80
|
-
export function parseTapOutput(text) {
|
|
81
|
-
const passed = [];
|
|
82
|
-
const failed = [];
|
|
83
|
-
const lines = String(text ?? '').split('\n');
|
|
84
|
-
const stack = [];
|
|
85
|
-
let pending = null;
|
|
86
|
-
|
|
87
|
-
const flush = () => {
|
|
88
|
-
if (!pending) return;
|
|
89
|
-
if (pending.type !== 'suite' && !pending.hadChild && !pending.directive) {
|
|
90
|
-
const id = [...pending.ancestors, pending.name].join(' > ');
|
|
91
|
-
if (pending.ok) passed.push(id);
|
|
92
|
-
else failed.push(id);
|
|
93
|
-
}
|
|
94
|
-
pending = null;
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
for (const raw of lines) {
|
|
98
|
-
const sub = /^(\s*)# Subtest:\s+(.*)$/.exec(raw);
|
|
99
|
-
if (sub) {
|
|
100
|
-
flush();
|
|
101
|
-
const indent = sub[1].length;
|
|
102
|
-
while (stack.length > 0 && stack[stack.length - 1].indent >= indent) {
|
|
103
|
-
stack.pop();
|
|
104
|
-
}
|
|
105
|
-
if (stack.length > 0) {
|
|
106
|
-
stack[stack.length - 1].hadChild = true;
|
|
107
|
-
}
|
|
108
|
-
stack.push({ indent, name: sub[2].trim(), hadChild: false });
|
|
109
|
-
continue;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const res = /^(\s*)(not ok|ok)\s+\d+(?:\s+-\s+(.*?))?(?:\s+#\s*(SKIP|TODO)\b.*)?$/i.exec(raw);
|
|
113
|
-
if (res) {
|
|
114
|
-
flush();
|
|
115
|
-
const indent = res[1].length;
|
|
116
|
-
while (stack.length > 0 && stack[stack.length - 1].indent > indent) {
|
|
117
|
-
stack.pop();
|
|
118
|
-
}
|
|
119
|
-
let hadChild = false;
|
|
120
|
-
if (stack.length > 0 && stack[stack.length - 1].indent === indent) {
|
|
121
|
-
hadChild = stack.pop().hadChild;
|
|
122
|
-
}
|
|
123
|
-
const ancestors = stack.map((s) => s.name);
|
|
124
|
-
const ok = res[2].toLowerCase() === 'ok';
|
|
125
|
-
const name = (res[3] ?? '').trim();
|
|
126
|
-
const directive = res[4] ? res[4].toUpperCase() : null;
|
|
127
|
-
if (name) {
|
|
128
|
-
pending = { indent, ok, name, ancestors, hadChild, directive, type: null };
|
|
129
|
-
}
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (pending) {
|
|
134
|
-
const typeMatch = /^\s*type:\s*['"]?(test|suite)['"]?\s*$/.exec(raw);
|
|
135
|
-
if (typeMatch) {
|
|
136
|
-
pending.type = typeMatch[1];
|
|
137
|
-
} else if (/^\s*\.\.\.\s*$/.test(raw)) {
|
|
138
|
-
flush();
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
flush();
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
passed: dedupeSorted(passed),
|
|
146
|
-
failed: dedupeSorted(failed),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Parse Vitest `--reporter=json` output.
|
|
152
|
-
*
|
|
153
|
-
* @param {string} text
|
|
154
|
-
* @returns {{passed: string[], failed: string[]}}
|
|
155
|
-
*/
|
|
156
|
-
export function parseVitestJson(text) {
|
|
157
|
-
const passed = [];
|
|
158
|
-
const failed = [];
|
|
159
|
-
const raw = String(text ?? '').trim();
|
|
160
|
-
const start = raw.indexOf('{');
|
|
161
|
-
const end = raw.lastIndexOf('}');
|
|
162
|
-
if (start === -1 || end === -1 || end < start) return { passed, failed };
|
|
163
|
-
|
|
164
|
-
let doc;
|
|
165
|
-
try {
|
|
166
|
-
doc = JSON.parse(raw.slice(start, end + 1));
|
|
167
|
-
} catch {
|
|
168
|
-
return { passed, failed };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
for (const suite of doc.testResults ?? []) {
|
|
172
|
-
for (const assertion of suite.assertionResults ?? []) {
|
|
173
|
-
const parts = [...(assertion.ancestorTitles ?? []), assertion.title].filter(Boolean);
|
|
174
|
-
const id = parts.length > 0 ? parts.join(' > ') : (assertion.fullName ?? '').trim();
|
|
175
|
-
if (!id) continue;
|
|
176
|
-
if (assertion.status === 'passed') passed.push(id);
|
|
177
|
-
else if (assertion.status === 'failed') failed.push(id);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
passed: dedupeSorted(passed),
|
|
183
|
-
failed: dedupeSorted(failed),
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Parse JUnit XML output (used by pytest `--junitxml`, Gradle and Maven).
|
|
189
|
-
*
|
|
190
|
-
* @param {string} xml
|
|
191
|
-
* @returns {{passed: string[], failed: string[]}}
|
|
192
|
-
*/
|
|
193
|
-
export function parseJunitXml(xml) {
|
|
194
|
-
const passed = [];
|
|
195
|
-
const failed = [];
|
|
196
|
-
const source = String(xml ?? '');
|
|
197
|
-
const tagRe = /<testcase\b([^>]*?)(?:\/>|>([\s\S]*?)<\/testcase>)/g;
|
|
198
|
-
|
|
199
|
-
for (const match of source.matchAll(tagRe)) {
|
|
200
|
-
const attrs = match[1] ?? '';
|
|
201
|
-
const inner = match[2] ?? '';
|
|
202
|
-
const name = xmlAttr(attrs, 'name');
|
|
203
|
-
const classname = xmlAttr(attrs, 'classname');
|
|
204
|
-
if (!name) continue;
|
|
205
|
-
if (/<skipped\b/i.test(inner)) continue;
|
|
206
|
-
|
|
207
|
-
const id = classname ? `${classname} > ${name}` : name;
|
|
208
|
-
if (/<(?:failure|error)\b/i.test(inner)) failed.push(id);
|
|
209
|
-
else passed.push(id);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
passed: dedupeSorted(passed),
|
|
214
|
-
failed: dedupeSorted(failed),
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function xmlAttr(attrs, key) {
|
|
219
|
-
const m = new RegExp(`\\b${key}=["']([^"']*)["']`).exec(attrs);
|
|
220
|
-
if (!m) return '';
|
|
221
|
-
return m[1]
|
|
222
|
-
.replaceAll('"', '"')
|
|
223
|
-
.replaceAll(''', "'")
|
|
224
|
-
.replaceAll('<', '<')
|
|
225
|
-
.replaceAll('>', '>')
|
|
226
|
-
.replaceAll('&', '&')
|
|
227
|
-
.trim();
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Parse `go test -json` JSONL event stream.
|
|
232
|
-
*
|
|
233
|
-
* Only leaf test events (carrying `Test`) are recorded; package-level summary
|
|
234
|
-
* events without `Test` are ignored.
|
|
235
|
-
*
|
|
236
|
-
* @param {string} text
|
|
237
|
-
* @returns {{passed: string[], failed: string[]}}
|
|
238
|
-
*/
|
|
239
|
-
export function parseGoTestJson(text) {
|
|
240
|
-
const passed = [];
|
|
241
|
-
const failed = [];
|
|
242
|
-
for (const line of String(text ?? '').split('\n')) {
|
|
243
|
-
const trimmed = line.trim();
|
|
244
|
-
if (!trimmed.startsWith('{')) continue;
|
|
245
|
-
let ev;
|
|
246
|
-
try {
|
|
247
|
-
ev = JSON.parse(trimmed);
|
|
248
|
-
} catch {
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
if (typeof ev.Test !== 'string' || ev.Test.length === 0) continue;
|
|
252
|
-
const id = ev.Package ? `${ev.Package} > ${ev.Test}` : ev.Test;
|
|
253
|
-
if (ev.Action === 'pass') passed.push(id);
|
|
254
|
-
else if (ev.Action === 'fail') failed.push(id);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return {
|
|
258
|
-
passed: dedupeSorted(passed),
|
|
259
|
-
failed: dedupeSorted(failed),
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Parse libtest's text output, which `cargo test` prints for every test binary.
|
|
265
|
-
*
|
|
266
|
-
* A line is a result only when it has the `test <name> ... <status>` shape.
|
|
267
|
-
* Harness summaries (`test result: ok.`), compile output and doctest prose are
|
|
268
|
-
* not results. `ignored` is neither a pass nor a failure: counting it as one
|
|
269
|
-
* would make a skipped test look like evidence the change works.
|
|
270
|
-
*
|
|
271
|
-
* @param {string} text
|
|
272
|
-
* @returns {{passed: string[], failed: string[]}}
|
|
273
|
-
*/
|
|
274
|
-
export function parseCargoTestOutput(text) {
|
|
275
|
-
const passed = [];
|
|
276
|
-
const failed = [];
|
|
277
|
-
for (const line of String(text ?? '').split('\n')) {
|
|
278
|
-
const match = /^test\s+(.+?)\s+\.\.\.\s+(ok|FAILED|ignored)\s*$/.exec(line.trimEnd());
|
|
279
|
-
if (!match || match[2] === 'ignored') continue;
|
|
280
|
-
(match[2] === 'ok' ? passed : failed).push(match[1]);
|
|
281
|
-
}
|
|
282
|
-
return {
|
|
283
|
-
passed: dedupeSorted(passed),
|
|
284
|
-
failed: dedupeSorted(failed),
|
|
285
|
-
};
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
/**
|
|
289
|
-
* Detect the test runner for a repository and build the command for a set of
|
|
290
|
-
* changed test files.
|
|
291
|
-
*
|
|
292
|
-
* @param {string} root
|
|
293
|
-
* @param {string[]} testFiles
|
|
294
|
-
* @returns {Promise<{runner: string, displayCmd: string, buildExec: (worktree: string) => {cmd: string, args: string[], reportPath?: string}, parse: (stdout: string, reportText?: string) => {passed: string[], failed: string[]}}|null>}
|
|
295
|
-
*/
|
|
296
|
-
export async function detectTestRunner(root, testFiles) {
|
|
297
|
-
const files = [...testFiles].sort();
|
|
298
|
-
|
|
299
|
-
if (existsSync(join(root, 'package.json'))) {
|
|
300
|
-
let pkg = {};
|
|
301
|
-
try {
|
|
302
|
-
pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
303
|
-
} catch {
|
|
304
|
-
pkg = {};
|
|
305
|
-
}
|
|
306
|
-
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
307
|
-
const testScript = String(pkg.scripts?.test ?? '');
|
|
308
|
-
if (deps.vitest || /\bvitest\b/.test(testScript)) {
|
|
309
|
-
const bin = join('node_modules', '.bin', 'vitest');
|
|
310
|
-
return {
|
|
311
|
-
runner: 'vitest',
|
|
312
|
-
displayCmd: `vitest run --reporter=json ${files.join(' ')}`,
|
|
313
|
-
buildExec: (wt) => ({
|
|
314
|
-
cmd: existsSync(join(wt, bin)) ? join(wt, bin) : 'vitest',
|
|
315
|
-
args: ['run', '--reporter=json', ...files],
|
|
316
|
-
}),
|
|
317
|
-
parse: (stdout) => parseVitestJson(stdout),
|
|
318
|
-
};
|
|
319
|
-
}
|
|
320
|
-
return {
|
|
321
|
-
runner: 'node-test',
|
|
322
|
-
displayCmd: `node --test ${files.join(' ')}`,
|
|
323
|
-
buildExec: () => ({
|
|
324
|
-
cmd: process.execPath,
|
|
325
|
-
args: ['--test', '--test-reporter=tap', ...files],
|
|
326
|
-
}),
|
|
327
|
-
parse: (stdout) => parseTapOutput(stdout),
|
|
328
|
-
};
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
if (existsSync(join(root, 'Cargo.toml'))) {
|
|
332
|
-
// Cargo has no "run this file" argument. An integration test is a target
|
|
333
|
-
// named after its file in `tests/`, so it can be selected with `--test`.
|
|
334
|
-
// A source file carries inline unit tests and can only be reached by the
|
|
335
|
-
// crate's test binary, so one such file means the whole crate runs.
|
|
336
|
-
const targets = dedupeSorted(
|
|
337
|
-
files.filter((f) => /^tests\/[^/]+\.rs$/.test(f)).map((f) => basename(f, '.rs')),
|
|
338
|
-
);
|
|
339
|
-
const integrationOnly = files.length > 0 && files.every((f) => /^tests\/[^/]+\.rs$/.test(f));
|
|
340
|
-
const args = ['test'];
|
|
341
|
-
if (integrationOnly) {
|
|
342
|
-
for (const target of targets) args.push('--test', target);
|
|
343
|
-
}
|
|
344
|
-
return {
|
|
345
|
-
runner: 'cargo-test',
|
|
346
|
-
displayCmd: ['cargo', ...args].join(' '),
|
|
347
|
-
buildExec: () => ({ cmd: 'cargo', args }),
|
|
348
|
-
parse: (stdout) => parseCargoTestOutput(stdout),
|
|
349
|
-
};
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
if (existsSync(join(root, 'go.mod'))) {
|
|
353
|
-
const pkgs = dedupeSorted(
|
|
354
|
-
files.map((f) => {
|
|
355
|
-
const dir = dirname(f);
|
|
356
|
-
return dir === '.' ? './...' : `./${dir}`;
|
|
357
|
-
}),
|
|
358
|
-
);
|
|
359
|
-
return {
|
|
360
|
-
runner: 'go-test',
|
|
361
|
-
displayCmd: `go test -json ${pkgs.join(' ')}`,
|
|
362
|
-
buildExec: () => ({
|
|
363
|
-
cmd: 'go',
|
|
364
|
-
args: ['test', '-json', ...pkgs],
|
|
365
|
-
}),
|
|
366
|
-
parse: (stdout) => parseGoTestJson(stdout),
|
|
367
|
-
};
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
if (
|
|
371
|
-
existsSync(join(root, 'pyproject.toml')) ||
|
|
372
|
-
existsSync(join(root, 'requirements.txt')) ||
|
|
373
|
-
existsSync(join(root, 'setup.py'))
|
|
374
|
-
) {
|
|
375
|
-
return {
|
|
376
|
-
runner: 'pytest',
|
|
377
|
-
displayCmd: `pytest ${files.join(' ')}`,
|
|
378
|
-
buildExec: (wt) => {
|
|
379
|
-
const reportPath = join(wt, '.bench-junit.xml');
|
|
380
|
-
return {
|
|
381
|
-
cmd: 'pytest',
|
|
382
|
-
args: [`--junitxml=${reportPath}`, ...files],
|
|
383
|
-
reportPath,
|
|
384
|
-
};
|
|
385
|
-
},
|
|
386
|
-
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (existsSync(join(root, 'build.gradle.kts')) || existsSync(join(root, 'build.gradle'))) {
|
|
391
|
-
const classes = files
|
|
392
|
-
.map((f) => basename(f).replace(/\.(?:java|kt)$/, ''))
|
|
393
|
-
.filter(Boolean);
|
|
394
|
-
const filterArgs = classes.flatMap((c) => ['--tests', `*${c}*`]);
|
|
395
|
-
return {
|
|
396
|
-
runner: 'gradle',
|
|
397
|
-
displayCmd: `./gradlew test ${filterArgs.join(' ')}`.trim(),
|
|
398
|
-
buildExec: (wt) => ({
|
|
399
|
-
cmd: existsSync(join(wt, 'gradlew')) ? join(wt, 'gradlew') : 'gradle',
|
|
400
|
-
args: ['test', ...filterArgs],
|
|
401
|
-
reportDir: join(wt, 'build', 'test-results', 'test'),
|
|
402
|
-
}),
|
|
403
|
-
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
if (existsSync(join(root, 'pom.xml'))) {
|
|
408
|
-
const classes = files
|
|
409
|
-
.map((f) => basename(f).replace(/\.(?:java|kt)$/, ''))
|
|
410
|
-
.filter(Boolean);
|
|
411
|
-
const arg = classes.length > 0 ? [`-Dtest=${classes.join(',')}`] : [];
|
|
412
|
-
return {
|
|
413
|
-
runner: 'maven',
|
|
414
|
-
displayCmd: `mvn test ${arg.join(' ')}`.trim(),
|
|
415
|
-
buildExec: () => ({
|
|
416
|
-
cmd: 'mvn',
|
|
417
|
-
args: ['test', ...arg],
|
|
418
|
-
}),
|
|
419
|
-
parse: (_stdout, reportText) => parseJunitXml(reportText ?? ''),
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
return null;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Resolve the hidden verifier vault root outside every worktree.
|
|
428
|
-
*
|
|
429
|
-
* Precedence:
|
|
430
|
-
* 1. `SIMT_DEV_VAULT_DIR` (explicit override for hermetic tests)
|
|
431
|
-
* 2. `$XDG_CACHE_HOME/simt-dev/vault/<repo-id>`
|
|
432
|
-
* 3. `~/Library/Caches/simt-dev/vault/<repo-id>` on macOS, or `~/.cache/simt-dev/vault/<repo-id>`
|
|
433
|
-
*
|
|
434
|
-
* @param {string} root
|
|
435
|
-
* @param {{env?: NodeJS.ProcessEnv, home?: string, platform?: string}} [options]
|
|
436
|
-
* @returns {string}
|
|
437
|
-
*/
|
|
438
|
-
export function resolveVaultDir(root, { env = process.env, home = homedir(), platform = osPlatform() } = {}) {
|
|
439
|
-
const repoId = repoIdentifier(root);
|
|
440
|
-
if (env.SIMT_DEV_VAULT_DIR) {
|
|
441
|
-
return join(resolve(env.SIMT_DEV_VAULT_DIR), repoId);
|
|
442
|
-
}
|
|
443
|
-
const cacheBase = env.XDG_CACHE_HOME
|
|
444
|
-
? resolve(env.XDG_CACHE_HOME)
|
|
445
|
-
: platform === 'darwin'
|
|
446
|
-
? join(home, 'Library', 'Caches')
|
|
447
|
-
: join(home, '.cache');
|
|
448
|
-
return join(cacheBase, CONFIG_DIR, 'vault', repoId);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function repoIdentifier(root) {
|
|
452
|
-
const full = resolve(root);
|
|
453
|
-
const name = basename(full).replace(/[^A-Za-z0-9._-]/g, '-') || 'repo';
|
|
454
|
-
const digest = createHash('sha256').update(full).digest('hex').slice(0, 8);
|
|
455
|
-
return `${name}-${digest}`;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Compute diff stats (`files`, `linesAdded`, `linesRemoved`) from a unified diff buffer.
|
|
460
|
-
*
|
|
461
|
-
* @param {Buffer|string} patch
|
|
462
|
-
* @param {number} fileCount
|
|
463
|
-
* @returns {{files: number, linesAdded: number, linesRemoved: number}}
|
|
34
|
+
* Test paths excluded by default, overridable via `simt.json` `bench.mine.exclude`
|
|
35
|
+
* (review 0003 Q48: the pack integration test was hardcoded here, a repo fact, not a miner fact).
|
|
464
36
|
*/
|
|
37
|
+
const DEFAULT_EXCLUDES = ['test/pack.test.mjs'];
|
|
465
38
|
export function patchStats(patch, fileCount) {
|
|
466
39
|
const text = Buffer.isBuffer(patch) ? patch.toString('utf8') : String(patch ?? '');
|
|
467
40
|
let linesAdded = 0;
|
|
@@ -519,7 +92,11 @@ export async function listCandidates(root) {
|
|
|
519
92
|
|
|
520
93
|
const cleanSubject = (subject ?? '').trim();
|
|
521
94
|
const cleanBody = (body ?? '').trim();
|
|
522
|
-
|
|
95
|
+
// Subject only by default. A commit body usually narrates the fix ("use x * x * x
|
|
96
|
+
// here"), which hands the agent the answer and measures nothing; a repository whose
|
|
97
|
+
// messages are curated can ask for the whole message with `bench.mine.prompt: "body"`
|
|
98
|
+
// (review 0003 Q49).
|
|
99
|
+
const prompt = cleanSubject;
|
|
523
100
|
|
|
524
101
|
candidates.push({
|
|
525
102
|
sha: sha.trim(),
|
|
@@ -584,15 +161,16 @@ function runProcess(cmd, args, { cwd, input, env, timeoutMs = 15000 } = {}) {
|
|
|
584
161
|
|
|
585
162
|
async function executeRunner(runner, worktree, env, timeoutMs) {
|
|
586
163
|
const spec = runner.buildExec(worktree);
|
|
587
|
-
|
|
588
|
-
|
|
164
|
+
// Remove what a previous run left behind before running: a stale report
|
|
165
|
+
// would parse as this run's result, and a directory report accumulates
|
|
166
|
+
// one XML per class across runs.
|
|
167
|
+
for (const p of spec.reportPaths ?? []) {
|
|
168
|
+
if (existsSync(p)) await rm(p, { recursive: true, force: true });
|
|
589
169
|
}
|
|
590
170
|
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
|
-
}
|
|
171
|
+
const reportText = await readReports(spec.reportPaths);
|
|
595
172
|
const parsed = runner.parse(res.stdout, reportText);
|
|
173
|
+
await cleanupReports(spec.reportPaths);
|
|
596
174
|
return {
|
|
597
175
|
code: res.code,
|
|
598
176
|
timedOut: res.timedOut,
|
|
@@ -606,10 +184,20 @@ async function executeRunner(runner, worktree, env, timeoutMs) {
|
|
|
606
184
|
* twice, dropping any candidate whose test outcomes are not identical across both
|
|
607
185
|
* passes.
|
|
608
186
|
*/
|
|
609
|
-
async function validateCandidate(
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
187
|
+
async function validateCandidate(
|
|
188
|
+
root,
|
|
189
|
+
worktree,
|
|
190
|
+
candidate,
|
|
191
|
+
{ timeoutMs = 5000, exclude = DEFAULT_EXCLUDES, promptMode = 'subject' } = {},
|
|
192
|
+
) {
|
|
193
|
+
// Skip commits that modify more than 4 test files, or that touch a path
|
|
194
|
+
// the repository's manifest excludes (`simt.json` bench.mine.exclude,
|
|
195
|
+
// defaulting to the pack integration test the committed corpus was mined
|
|
196
|
+
// around): sweeping renames rather than self-contained benchmark tasks.
|
|
197
|
+
if (
|
|
198
|
+
candidate.testFiles.length > 4 ||
|
|
199
|
+
candidate.testFiles.some((f) => exclude.includes(f))
|
|
200
|
+
) {
|
|
613
201
|
return null;
|
|
614
202
|
}
|
|
615
203
|
|
|
@@ -682,17 +270,23 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
|
|
|
682
270
|
const p2p = gold1.passed.filter((id) => redPassedSet.has(id) && !redFailedSet.has(id));
|
|
683
271
|
if (f2p.length === 0) return null;
|
|
684
272
|
|
|
685
|
-
|
|
686
|
-
|
|
273
|
+
// 12 hex chars of the source commit: the 7-char form plus a fixed `-01`
|
|
274
|
+
// suffix could not disambiguate two tasks from the same commit (review 0003
|
|
275
|
+
// Q48). Collisions are checked at write time, where the corpus is visible.
|
|
276
|
+
const id = `t-${candidate.sha.slice(0, 12)}-01`;
|
|
687
277
|
const gold = patchStats(goldPatch, candidate.implFiles.length);
|
|
688
278
|
|
|
689
279
|
return {
|
|
690
280
|
task: {
|
|
691
|
-
schemaVersion:
|
|
281
|
+
schemaVersion: TASK_SCHEMA_VERSION,
|
|
692
282
|
id,
|
|
693
283
|
base: candidate.base,
|
|
694
284
|
sourceCommit: candidate.sha,
|
|
695
|
-
prompt:
|
|
285
|
+
prompt:
|
|
286
|
+
promptMode === 'body' && candidate.body
|
|
287
|
+
? `${candidate.subject}\n\n${candidate.body}`
|
|
288
|
+
: candidate.subject,
|
|
289
|
+
promptBasis: promptMode === 'body' && candidate.body ? 'body' : 'subject',
|
|
696
290
|
testCmd: runner.displayCmd,
|
|
697
291
|
f2p,
|
|
698
292
|
p2p,
|
|
@@ -713,8 +307,12 @@ async function validateCandidate(root, worktree, candidate, { timeoutMs = 5000 }
|
|
|
713
307
|
* temporary worktrees until `target` valid tasks are collected, and written
|
|
714
308
|
* deterministically to disk.
|
|
715
309
|
*
|
|
310
|
+
* Writing is append-only: existing task files are left alone unless `replace`
|
|
311
|
+
* is set, which restores the old "reflect exactly this history" semantics by
|
|
312
|
+
* deleting files the current history does not reproduce (review 0003 Q48).
|
|
313
|
+
*
|
|
716
314
|
* @param {string} root
|
|
717
|
-
* @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, env?: NodeJS.ProcessEnv}} [options]
|
|
315
|
+
* @param {{apply?: boolean, target?: number, concurrency?: number, timeoutMs?: number, replace?: boolean, env?: NodeJS.ProcessEnv}} [options]
|
|
718
316
|
*/
|
|
719
317
|
export async function mineTasks(
|
|
720
318
|
root,
|
|
@@ -723,6 +321,7 @@ export async function mineTasks(
|
|
|
723
321
|
target = DEFAULT_MINE_TARGET,
|
|
724
322
|
concurrency = 4,
|
|
725
323
|
timeoutMs = 5000,
|
|
324
|
+
replace = false,
|
|
726
325
|
env = process.env,
|
|
727
326
|
} = {},
|
|
728
327
|
) {
|
|
@@ -732,12 +331,20 @@ export async function mineTasks(
|
|
|
732
331
|
return { ok: false, error: listed.error };
|
|
733
332
|
}
|
|
734
333
|
|
|
735
|
-
|
|
334
|
+
// Repository-declared mining config (review 0003 Q48): an explicit option
|
|
335
|
+
// wins, then the manifest, then the default.
|
|
336
|
+
const { manifest } = await readManifest(absRoot);
|
|
337
|
+
const benchCfg = manifest?.bench?.mine ?? {};
|
|
338
|
+
const effectiveTimeout = benchCfg.timeoutMs ?? timeoutMs;
|
|
339
|
+
const exclude = benchCfg.exclude ?? DEFAULT_EXCLUDES;
|
|
340
|
+
const promptMode = benchCfg.prompt === 'body' ? 'body' : 'subject';
|
|
341
|
+
|
|
342
|
+
const tasksDir = join(absRoot, STATE_DIR, 'bench', 'tasks');
|
|
736
343
|
const vaultRoot = resolveVaultDir(absRoot, { env });
|
|
737
344
|
|
|
738
345
|
if (!apply) {
|
|
739
346
|
const preview = listed.candidates.slice(0, target).map((c) => ({
|
|
740
|
-
id: `t-${c.sha.slice(0,
|
|
347
|
+
id: `t-${c.sha.slice(0, 12)}-01`,
|
|
741
348
|
base: c.base,
|
|
742
349
|
sourceCommit: c.sha,
|
|
743
350
|
subject: c.subject,
|
|
@@ -790,7 +397,11 @@ export async function mineTasks(
|
|
|
790
397
|
cursor += 1;
|
|
791
398
|
if (index >= listed.candidates.length) break;
|
|
792
399
|
const candidate = listed.candidates[index];
|
|
793
|
-
const res = await validateCandidate(absRoot, wt, candidate, {
|
|
400
|
+
const res = await validateCandidate(absRoot, wt, candidate, {
|
|
401
|
+
timeoutMs: effectiveTimeout,
|
|
402
|
+
exclude,
|
|
403
|
+
promptMode,
|
|
404
|
+
});
|
|
794
405
|
if (res) {
|
|
795
406
|
validated.push({ index, ...res });
|
|
796
407
|
}
|
|
@@ -805,28 +416,68 @@ export async function mineTasks(
|
|
|
805
416
|
const selected = validated.slice(0, target);
|
|
806
417
|
|
|
807
418
|
await mkdir(tasksDir, { recursive: true });
|
|
808
|
-
await mkdir(vaultRoot, { recursive: true });
|
|
809
|
-
|
|
810
|
-
//
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
419
|
+
await mkdir(vaultRoot, { recursive: true, mode: 0o700 });
|
|
420
|
+
// The hidden tests are the answer key: 0700 keeps every other local account out.
|
|
421
|
+
// `mkdir` alone does not tighten a vault an earlier run created.
|
|
422
|
+
await chmod(vaultRoot, 0o700).catch(() => {});
|
|
423
|
+
|
|
424
|
+
// Mining is append-only (review 0003 Q48: `mineTasks(target 3)` once
|
|
425
|
+
// deleted all 20 committed tasks and wrote 3 others). `replace` restores
|
|
426
|
+
// the old "reflect exactly this history" semantics, and only it deletes.
|
|
427
|
+
if (replace) {
|
|
428
|
+
const existing = await readdir(tasksDir).catch(() => []);
|
|
429
|
+
const keepNames = new Set(selected.map((entry) => `${entry.task.id}.json`));
|
|
430
|
+
for (const name of existing) {
|
|
431
|
+
if (name.endsWith('.json') && !keepNames.has(name)) {
|
|
432
|
+
await rm(join(tasksDir, name), { force: true });
|
|
433
|
+
}
|
|
817
434
|
}
|
|
818
435
|
}
|
|
819
436
|
|
|
820
437
|
for (const entry of selected) {
|
|
821
438
|
const taskPath = join(tasksDir, `${entry.task.id}.json`);
|
|
822
|
-
|
|
439
|
+
// An id belongs to exactly one source commit. If a file with this id
|
|
440
|
+
// holds a different commit, the 12-char prefix collided and guessing
|
|
441
|
+
// which corpus the reader wants is worse than refusing.
|
|
442
|
+
if (existsSync(taskPath) && !replace) {
|
|
443
|
+
const prior = JSON.parse(await readFile(taskPath, 'utf8'));
|
|
444
|
+
if (prior.sourceCommit !== entry.task.sourceCommit) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
applied: false,
|
|
448
|
+
error: `id collision: ${taskPath} holds task from ${prior.sourceCommit}, mine produced ${entry.task.sourceCommit}; rerun with --replace to take over the corpus`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
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
|
+
);
|
|
823
459
|
|
|
824
460
|
const taskVault = join(vaultRoot, entry.task.id);
|
|
825
|
-
await mkdir(taskVault, { recursive: true });
|
|
461
|
+
await mkdir(taskVault, { recursive: true, mode: 0o700 });
|
|
462
|
+
await chmod(taskVault, 0o700).catch(() => {});
|
|
826
463
|
await writeFile(join(taskVault, 'test.patch'), entry.testPatch);
|
|
827
464
|
await writeFile(join(taskVault, 'gold.patch'), entry.goldPatch);
|
|
828
465
|
}
|
|
829
466
|
|
|
467
|
+
// The corpus records where it came from: which commit was HEAD when the
|
|
468
|
+
// mine ran, the target, and the simt version that validated the tasks.
|
|
469
|
+
// No timestamp — a re-mine at the same HEAD is byte-identical.
|
|
470
|
+
const { stdout: headSha } = await exec('git', ['rev-parse', 'HEAD'], { cwd: absRoot });
|
|
471
|
+
await writeFile(
|
|
472
|
+
join(dirname(tasksDir), 'corpus.json'),
|
|
473
|
+
`${JSON.stringify(
|
|
474
|
+
{ minedFrom: headSha.trim(), target, simtVersion: packageVersion() },
|
|
475
|
+
null,
|
|
476
|
+
2,
|
|
477
|
+
)}\n`,
|
|
478
|
+
'utf8',
|
|
479
|
+
);
|
|
480
|
+
|
|
830
481
|
return {
|
|
831
482
|
ok: true,
|
|
832
483
|
applied: true,
|
|
@@ -844,11 +495,6 @@ export async function mineTasks(
|
|
|
844
495
|
await rm(sandbox, { recursive: true, force: true }).catch(() => null);
|
|
845
496
|
}
|
|
846
497
|
}
|
|
847
|
-
|
|
848
|
-
function dedupeSorted(items) {
|
|
849
|
-
return [...new Set(items)].sort();
|
|
850
|
-
}
|
|
851
|
-
|
|
852
498
|
function sameList(a, b) {
|
|
853
499
|
if (a.length !== b.length) return false;
|
|
854
500
|
for (let i = 0; i < a.length; i += 1) {
|