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
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// Report what did not run. The summary names every gate, its disposition and
|
|
19
19
|
// its reason, so "passed" can never be mistaken for "only some of it passed".
|
|
20
20
|
|
|
21
|
-
import { execFile } from 'node:child_process';
|
|
21
|
+
import { execFile, spawn } from 'node:child_process';
|
|
22
22
|
import { mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { basename, join, matchesGlob, resolve, sep } from 'node:path';
|
|
@@ -30,11 +30,120 @@ import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
|
|
|
30
30
|
import { CLI } from '../name.mjs';
|
|
31
31
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
32
32
|
import { assertVersionPin } from '../package-version.mjs';
|
|
33
|
-
import { recordRun } from '../ledger/index.mjs';
|
|
33
|
+
import { hasTestEvidence, readLedger, recordRun } from '../ledger/index.mjs';
|
|
34
34
|
import { renderFindings, scanStaged } from '../secrets.mjs';
|
|
35
35
|
|
|
36
36
|
const exec = promisify(execFile);
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The default bound on one gate, in seconds, and the grace a killed gate gets before
|
|
40
|
+
* SIGKILL. A hook that runs on every commit needs a ceiling: a test runner waiting on
|
|
41
|
+
* a socket that never closes blocks the commit with no output and no way to tell a
|
|
42
|
+
* hang from slow work. `gates.timeout` raises or removes it per repository.
|
|
43
|
+
*/
|
|
44
|
+
const DEFAULT_GATE_TIMEOUT_SECONDS = 600;
|
|
45
|
+
const KILL_GRACE_MS = 5000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How much of a gate's output a report can carry. This is a bound on what is KEPT, not
|
|
49
|
+
* on what the process may write: a gate whose output exceeded execFile's buffer used to
|
|
50
|
+
* be reported `failed` although it exited 0.
|
|
51
|
+
*/
|
|
52
|
+
const GATE_OUTPUT_TAIL_BYTES = 64 * 1024;
|
|
53
|
+
|
|
54
|
+
function tailCollector() {
|
|
55
|
+
let kept = Buffer.alloc(0);
|
|
56
|
+
let dropped = 0;
|
|
57
|
+
return {
|
|
58
|
+
push(chunk) {
|
|
59
|
+
kept = Buffer.concat([kept, chunk]);
|
|
60
|
+
if (kept.length > GATE_OUTPUT_TAIL_BYTES) {
|
|
61
|
+
dropped += kept.length - GATE_OUTPUT_TAIL_BYTES;
|
|
62
|
+
kept = kept.subarray(kept.length - GATE_OUTPUT_TAIL_BYTES);
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
text: () => kept.toString('utf8'),
|
|
66
|
+
dropped: () => dropped,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Signal the gate's whole process group. A test runner spawns workers of its own, and
|
|
72
|
+
* signalling only the leader leaves them holding the pipes this function is waiting on.
|
|
73
|
+
* `detached` in the spawn below is what makes the group exist.
|
|
74
|
+
*/
|
|
75
|
+
function signalTree(child, signal) {
|
|
76
|
+
try {
|
|
77
|
+
process.kill(-child.pid, signal);
|
|
78
|
+
} catch {
|
|
79
|
+
try {
|
|
80
|
+
child.kill(signal);
|
|
81
|
+
} catch {
|
|
82
|
+
// Already gone: a second signal has nothing to reach.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run one gate to completion, keeping its exit code and the tail of what it printed.
|
|
89
|
+
*
|
|
90
|
+
* `timeoutMs` of 0 means no bound, which is what `gates.timeout: 0` asks for and what
|
|
91
|
+
* the manifest does not have to say. Output is collected in full while the process
|
|
92
|
+
* runs and only the tail is retained, so a noisy passing gate passes.
|
|
93
|
+
*/
|
|
94
|
+
async function runBounded(argv, { cwd, env, timeoutMs }) {
|
|
95
|
+
return await new Promise((settle) => {
|
|
96
|
+
let child;
|
|
97
|
+
try {
|
|
98
|
+
child = spawn(argv[0], argv.slice(1), { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
settle({ code: 1, stdout: '', stderr: String(error.message ?? error), timedOut: false, dropped: 0 });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const out = tailCollector();
|
|
104
|
+
const err = tailCollector();
|
|
105
|
+
let timedOut = false;
|
|
106
|
+
let timer = null;
|
|
107
|
+
let killer = null;
|
|
108
|
+
let done = false;
|
|
109
|
+
const finish = (code) => {
|
|
110
|
+
if (done) return;
|
|
111
|
+
done = true;
|
|
112
|
+
clearTimeout(timer);
|
|
113
|
+
clearTimeout(killer);
|
|
114
|
+
settle({ code, stdout: out.text(), stderr: err.text(), timedOut, dropped: out.dropped() + err.dropped() });
|
|
115
|
+
};
|
|
116
|
+
child.stdout?.on('data', (chunk) => out.push(chunk));
|
|
117
|
+
child.stderr?.on('data', (chunk) => err.push(chunk));
|
|
118
|
+
// A missing binary emits `error` and no `close`; the message is the only evidence.
|
|
119
|
+
child.on('error', (error) => {
|
|
120
|
+
err.push(Buffer.from(String(error.message ?? error)));
|
|
121
|
+
finish(1);
|
|
122
|
+
});
|
|
123
|
+
child.on('close', (code, signal) => finish(typeof code === 'number' ? code : signal === null ? 0 : 1));
|
|
124
|
+
if (timeoutMs > 0) {
|
|
125
|
+
timer = setTimeout(() => {
|
|
126
|
+
timedOut = true;
|
|
127
|
+
signalTree(child, 'SIGTERM');
|
|
128
|
+
killer = setTimeout(() => signalTree(child, 'SIGKILL'), KILL_GRACE_MS);
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The bound for one gate, in milliseconds. A number applies to every gate; a map applies
|
|
136
|
+
* per name, the shape `gates.cwd` already has. A value that is neither a positive whole
|
|
137
|
+
* number of seconds nor 0 falls back to the default rather than removing the bound: an
|
|
138
|
+
* unreadable declaration must not be the quiet way to disable the ceiling.
|
|
139
|
+
*/
|
|
140
|
+
function gateTimeoutMs(declared, name) {
|
|
141
|
+
const value = declared !== null && typeof declared === 'object' ? declared[name] : declared;
|
|
142
|
+
if (value === 0) return 0;
|
|
143
|
+
if (Number.isInteger(value) && value > 0) return value * 1000;
|
|
144
|
+
return DEFAULT_GATE_TIMEOUT_SECONDS * 1000;
|
|
145
|
+
}
|
|
146
|
+
|
|
38
147
|
/** Gates that are known but not required for the event to pass. */
|
|
39
148
|
const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
|
|
40
149
|
|
|
@@ -149,13 +258,15 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
|
149
258
|
let count, names;
|
|
150
259
|
try {
|
|
151
260
|
({ stdout: count } = await exec('git', ['rev-list', '--count', `${remoteSha}..${localSha}`], { cwd: root }));
|
|
152
|
-
({ stdout: names } = await exec('git', ['diff', '--name-only', `${remoteSha}..${localSha}`], { cwd: root }));
|
|
261
|
+
({ stdout: names } = await exec('git', ['diff', '--name-only', '-z', `${remoteSha}..${localSha}`], { cwd: root }));
|
|
153
262
|
} catch (error) {
|
|
154
263
|
return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
|
|
155
264
|
}
|
|
156
265
|
commits += Number(count.trim());
|
|
157
|
-
|
|
158
|
-
|
|
266
|
+
// `-z` and no trimming: a path is NUL-terminated because a path can contain a
|
|
267
|
+
// newline, and git quotes such a name in the line-terminated form, so splitting on
|
|
268
|
+
// newlines compares a quoted path against an unquoted glob and never matches.
|
|
269
|
+
for (const name of names.split('\0')) {
|
|
159
270
|
if (name !== '') files.add(name);
|
|
160
271
|
}
|
|
161
272
|
}
|
|
@@ -178,7 +289,11 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
|
178
289
|
if (commits > 0) parts.push(`${commits} commit(s) touching ${files.size} file(s), all inside the docs-only paths`);
|
|
179
290
|
if (tags.length > 0) parts.push(`${tags.length} tag(s) already delivered`);
|
|
180
291
|
if (deletions > 0) parts.push(`${deletions} ref deletion(s)`);
|
|
181
|
-
return { docsOnly: true, reason:
|
|
292
|
+
if (parts.length === 0) return { docsOnly: true, reason: 'no commits carried' };
|
|
293
|
+
// The report prints this as-is (ADR-113). The label names what is being pushed:
|
|
294
|
+
// a push that carries only tags is a tag push, not a docs-only push.
|
|
295
|
+
const kind = commits > 0 ? 'docs-only push' : tags.length > 0 ? 'tag push' : 'deletion push';
|
|
296
|
+
return { docsOnly: true, reason: `${kind}: ${parts.join('; ')}` };
|
|
182
297
|
}
|
|
183
298
|
|
|
184
299
|
/**
|
|
@@ -189,7 +304,7 @@ export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
|
189
304
|
* review. A misdeclaration (missing fields) also runs the suite - a skip the
|
|
190
305
|
* manifest cannot state precisely is a skip a reviewer cannot see.
|
|
191
306
|
*
|
|
192
|
-
* @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
307
|
+
* @returns {Promise<{reason: string, adr: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
193
308
|
*/
|
|
194
309
|
async function resolveDocsOnlySkip(root, manifest, pushed) {
|
|
195
310
|
if (pushed === undefined) return null;
|
|
@@ -200,7 +315,44 @@ async function resolveDocsOnlySkip(root, manifest, pushed) {
|
|
|
200
315
|
return null; // the caller cannot be sure what is being pushed; the suite runs
|
|
201
316
|
}
|
|
202
317
|
const verdict = await classifyPush(root, refs, { maxCommits: config.maxCommits, paths: config.paths });
|
|
203
|
-
return verdict.docsOnly ? { reason: verdict.reason } : null;
|
|
318
|
+
return verdict.docsOnly ? { reason: verdict.reason, adr: 'ADR-113' } : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The pre-push skip for a suite the author already ran on this exact tree (ADR-120).
|
|
323
|
+
*
|
|
324
|
+
* The dispatcher records every gate run against the tree it ran on (ADR-087). If a passing
|
|
325
|
+
* `test` run is recorded for the committed tree about to be pushed, and nothing has changed
|
|
326
|
+
* since, re-running the full suite at push time buys nothing: it is the same tree, already
|
|
327
|
+
* proven. This reads that evidence and earns the same reported skip the docs-only path does.
|
|
328
|
+
*
|
|
329
|
+
* It matches the COMMITTED tree (`HEAD^{tree}`), not the working tree: the pre-push gate runs
|
|
330
|
+
* at HEAD, so the evidence that matters is a pass on exactly that tree. A file edited after
|
|
331
|
+
* the green pass changes the tree id, so the match fails and the suite runs - the "no changes
|
|
332
|
+
* since" condition is enforced by the digest, not by trusting the author. Every uncertainty -
|
|
333
|
+
* an unborn HEAD, an unreadable ledger, no entry for this tree, a later failure - fails closed
|
|
334
|
+
* to the suite.
|
|
335
|
+
*
|
|
336
|
+
* @param {string} root the real repository root
|
|
337
|
+
* @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
338
|
+
*/
|
|
339
|
+
async function resolveAlreadyPassedSkip(root) {
|
|
340
|
+
let headTree;
|
|
341
|
+
try {
|
|
342
|
+
({ stdout: headTree } = await exec('git', ['rev-parse', 'HEAD^{tree}'], { cwd: root }));
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
headTree = headTree.trim();
|
|
347
|
+
if (!/^[0-9a-f]{40}$/.test(headTree)) return null;
|
|
348
|
+
const { entries, errors } = await readLedger(root);
|
|
349
|
+
if (errors.length > 0) return null;
|
|
350
|
+
if (!hasTestEvidence(entries, headTree)) return null;
|
|
351
|
+
return {
|
|
352
|
+
reason:
|
|
353
|
+
'already passing on current state: the test gate ran green on this tree and nothing has changed since',
|
|
354
|
+
adr: 'ADR-120',
|
|
355
|
+
};
|
|
204
356
|
}
|
|
205
357
|
|
|
206
358
|
/**
|
|
@@ -263,10 +415,12 @@ export async function runGates(event, { root, manifest, pushed }) {
|
|
|
263
415
|
let headWorktree = null;
|
|
264
416
|
let skipTest = null;
|
|
265
417
|
if (event === 'pre-push') {
|
|
266
|
-
// Decided before the worktree exists: a push that carries only documentation
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
418
|
+
// Decided before the worktree exists: a push that carries only documentation skips the
|
|
419
|
+
// `test` gate (ADR-113), and a push whose tree the author already proved green skips it
|
|
420
|
+
// on that evidence (ADR-120). A push that earns either skip needs no tree for a gate
|
|
421
|
+
// that will not run.
|
|
422
|
+
skipTest = (await resolveDocsOnlySkip(root, manifest, pushed)) ??
|
|
423
|
+
(await resolveAlreadyPassedSkip(root));
|
|
270
424
|
if (skipTest === null) {
|
|
271
425
|
const materialized = await materializeHeadWorktree(root);
|
|
272
426
|
if (materialized.path === null) {
|
|
@@ -312,8 +466,9 @@ export async function runGates(event, { root, manifest, pushed }) {
|
|
|
312
466
|
* @param {object} ctx.manifest
|
|
313
467
|
* @param {string[]} ctx.declared the gate names for this event
|
|
314
468
|
* @param {string} ctx.key the manifest key for this event
|
|
315
|
-
* @param {{reason: string} | null} [ctx.skipTest] set when a
|
|
316
|
-
* `test` gate (ADR-113
|
|
469
|
+
* @param {{reason: string, adr: string} | null} [ctx.skipTest] set when a push skips the
|
|
470
|
+
* `test` gate (ADR-113 for docs-only, ADR-120 for an already-green tree); the reason is
|
|
471
|
+
* what the report prints in its place, and the adr is the record that earned it
|
|
317
472
|
* @returns {Promise<{event: string, gates: object[], failed: boolean}>}
|
|
318
473
|
*/
|
|
319
474
|
async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, skipTest = null }) {
|
|
@@ -345,6 +500,9 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
345
500
|
// mean re-reading the command string to find out where anything runs, and a manifest
|
|
346
501
|
// written by an older release would have to be rewritten to gain the capability.
|
|
347
502
|
const cwds = manifest?.gates?.cwd ?? {};
|
|
503
|
+
// `gates.timeout` bounds one gate, in seconds: a number for every gate, or a map by
|
|
504
|
+
// name like `gates.cwd`. Absent means the default; 0 means no bound, declared.
|
|
505
|
+
const timeouts = manifest?.gates?.timeout;
|
|
348
506
|
const results = [];
|
|
349
507
|
// What the format gate rewrote, so restage touches those files and nothing else.
|
|
350
508
|
let formatted = [];
|
|
@@ -362,11 +520,12 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
362
520
|
continue;
|
|
363
521
|
}
|
|
364
522
|
|
|
365
|
-
// The one gate a
|
|
366
|
-
// dropped: a skip without a printed reason
|
|
367
|
-
// never ran", which is the exact confusion the report
|
|
523
|
+
// The one gate a push may not run (ADR-113 for a docs-only push, ADR-120 for a tree
|
|
524
|
+
// already proven green). It is reported, not dropped: a skip without a printed reason
|
|
525
|
+
// would let "passed" mean "the suite never ran", which is the exact confusion the report
|
|
526
|
+
// exists to prevent.
|
|
368
527
|
if (skipTest !== null && name === 'test') {
|
|
369
|
-
results.push({ name, status: 'skipped', detail:
|
|
528
|
+
results.push({ name, status: 'skipped', detail: skipTest.reason, adr: skipTest.adr, exitCode: null });
|
|
370
529
|
continue;
|
|
371
530
|
}
|
|
372
531
|
|
|
@@ -476,8 +635,9 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
476
635
|
cwd = resolved;
|
|
477
636
|
}
|
|
478
637
|
|
|
638
|
+
const timeoutMs = gateTimeoutMs(timeouts, name);
|
|
479
639
|
const started = Date.now();
|
|
480
|
-
const outcome = await runGate(resolution, cwd);
|
|
640
|
+
const outcome = await runGate(resolution, cwd, timeoutMs);
|
|
481
641
|
// The ledger is not the gate. A tree that cannot be named leaves no line, and the
|
|
482
642
|
// claim check then finds nothing, which is the direction that cannot invent a run.
|
|
483
643
|
try {
|
|
@@ -498,11 +658,18 @@ async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, sk
|
|
|
498
658
|
} catch {
|
|
499
659
|
// Recorded by absence: the next evidence read for this tree has no line.
|
|
500
660
|
}
|
|
661
|
+
// A killed gate has no exit code of its own (`signal` instead), so the reason is
|
|
662
|
+
// stated rather than left to `exit 1`, and the remedy says how to change the bound.
|
|
501
663
|
results.push({
|
|
502
664
|
name,
|
|
503
665
|
status: outcome.code === 0 ? 'passed' : 'failed',
|
|
504
|
-
detail:
|
|
666
|
+
detail: outcome.timedOut
|
|
667
|
+
? `${resolution.detail}: timed out after ${Math.round(timeoutMs / 1000)} s`
|
|
668
|
+
: `${resolution.detail}${outcome.code === 0 ? '' : `: exit ${outcome.code}`}`,
|
|
505
669
|
output: outcome.code === 0 ? '' : tail(outcome.stderr || outcome.stdout),
|
|
670
|
+
...(outcome.timedOut
|
|
671
|
+
? { remedy: `fix the hang, raise gates.timeout to give it longer, or set gates.timeout to 0 to remove the bound` }
|
|
672
|
+
: {}),
|
|
506
673
|
adr: gate.adr,
|
|
507
674
|
exitCode: outcome.code,
|
|
508
675
|
});
|
|
@@ -616,13 +783,8 @@ export function renderGateReport(event, declared, result) {
|
|
|
616
783
|
return lines;
|
|
617
784
|
}
|
|
618
785
|
|
|
619
|
-
async function run(argv, cwd, env) {
|
|
620
|
-
|
|
621
|
-
const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, env, maxBuffer: 8 * 1024 * 1024 });
|
|
622
|
-
return { code: 0, stdout, stderr };
|
|
623
|
-
} catch (error) {
|
|
624
|
-
return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
|
|
625
|
-
}
|
|
786
|
+
async function run(argv, cwd, env, timeoutMs) {
|
|
787
|
+
return await runBounded(argv, { cwd, env, timeoutMs });
|
|
626
788
|
}
|
|
627
789
|
|
|
628
790
|
/** The built-in gates this dispatcher can run, by name. */
|
|
@@ -693,24 +855,15 @@ async function runBuiltinGate(name, { root, staged, options }) {
|
|
|
693
855
|
* that the two sources stay distinguishable, which is why this is a separate branch
|
|
694
856
|
* rather than a flag on `run`.
|
|
695
857
|
*/
|
|
696
|
-
async function runGate(resolution, cwd) {
|
|
858
|
+
async function runGate(resolution, cwd, timeoutMs) {
|
|
697
859
|
// The push override is for the push itself, not for the gates the hook runs.
|
|
698
860
|
// A test that asserts a push is denied sees the override and fails.
|
|
699
861
|
const env = { ...process.env };
|
|
700
862
|
delete env.SIMT_ALLOW;
|
|
701
863
|
if (resolution.shell === true && typeof resolution.command === 'string') {
|
|
702
|
-
|
|
703
|
-
const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, env, maxBuffer: 8 * 1024 * 1024 });
|
|
704
|
-
return { code: 0, stdout, stderr };
|
|
705
|
-
} catch (error) {
|
|
706
|
-
return {
|
|
707
|
-
code: typeof error.code === 'number' ? error.code : 1,
|
|
708
|
-
stdout: error.stdout ?? '',
|
|
709
|
-
stderr: error.stderr ?? String(error.message ?? error),
|
|
710
|
-
};
|
|
711
|
-
}
|
|
864
|
+
return await run(['sh', '-c', resolution.command], cwd, env, timeoutMs);
|
|
712
865
|
}
|
|
713
|
-
return run(resolution.argv, cwd, env);
|
|
866
|
+
return await run(resolution.argv, cwd, env, timeoutMs);
|
|
714
867
|
}
|
|
715
868
|
|
|
716
869
|
async function readPkg(root) {
|
|
@@ -724,8 +877,10 @@ async function readPkg(root) {
|
|
|
724
877
|
/** Staged paths, or [] when git cannot answer (an unborn branch, for instance). */
|
|
725
878
|
export async function stagedFiles(root) {
|
|
726
879
|
try {
|
|
727
|
-
|
|
728
|
-
|
|
880
|
+
// `-z`: git quotes a path containing a newline or a quote in the line form, and a
|
|
881
|
+
// quoted name is not the path the index holds, so it never matches a staged blob.
|
|
882
|
+
const { stdout } = await exec('git', ['diff', '--cached', '--name-only', '-z', '--diff-filter=ACMR'], { cwd: root });
|
|
883
|
+
return stdout.split('\0').filter(Boolean);
|
|
729
884
|
} catch {
|
|
730
885
|
return [];
|
|
731
886
|
}
|
|
@@ -741,8 +896,8 @@ export async function stagedFiles(root) {
|
|
|
741
896
|
*/
|
|
742
897
|
async function unstagedFiles(root) {
|
|
743
898
|
try {
|
|
744
|
-
const { stdout } = await exec('git', ['diff', '--name-only'], { cwd: root });
|
|
745
|
-
return new Set(stdout.split('\
|
|
899
|
+
const { stdout } = await exec('git', ['diff', '--name-only', '-z'], { cwd: root });
|
|
900
|
+
return new Set(stdout.split('\0').filter(Boolean));
|
|
746
901
|
} catch {
|
|
747
902
|
return new Set();
|
|
748
903
|
}
|
package/src/hooks/gates.mjs
CHANGED
|
@@ -105,49 +105,6 @@ export function getGate(name) {
|
|
|
105
105
|
return GATES[name] ?? null;
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
/**
|
|
109
|
-
* A gate a repo cannot run is a FAILURE, never a skip.
|
|
110
|
-
*
|
|
111
|
-
* This is the load-bearing decision of the whole dispatcher. A gate that
|
|
112
|
-
* silently does nothing is indistinguishable from a gate that passed, and the
|
|
113
|
-
* documented consequence in this estate is an agent reaching for
|
|
114
|
-
* `--no-verify` - a repository removed its tests from pre-commit precisely
|
|
115
|
-
* because they were slow, and slow-plus-silent is what teaches everyone that
|
|
116
|
-
* hooks are advisory.
|
|
117
|
-
*/
|
|
118
|
-
export function unresolved(name, { hasScript, binary, command, stack }) {
|
|
119
|
-
const gate = getGate(name);
|
|
120
|
-
if (gate === null) {
|
|
121
|
-
return {
|
|
122
|
-
gate: name,
|
|
123
|
-
reason: `unknown gate; the shared dispatcher knows: ${Object.keys(GATES).sort().join(', ')}`,
|
|
124
|
-
remedy: `remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
if (gate.kind === 'script' && !hasScript(gate.script)) {
|
|
128
|
-
return {
|
|
129
|
-
gate: name,
|
|
130
|
-
reason: `no "${gate.script}" script in package.json`,
|
|
131
|
-
remedy: `add the script, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
if (gate.kind === 'command' && !command) {
|
|
135
|
-
return {
|
|
136
|
-
gate: name,
|
|
137
|
-
reason: `no command declared for this gate in ${MANIFEST_FILE}`,
|
|
138
|
-
remedy: `set gates.commands["${name}"] to the command that proves it, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
if (gate.kind === 'staged' && !binary) {
|
|
142
|
-
return {
|
|
143
|
-
gate: name,
|
|
144
|
-
reason: `${gate.tools.join('/')} is not installed in node_modules/.bin`,
|
|
145
|
-
remedy: `run the repo install command, or remove "format" from ${MANIFEST_FILE} gates`,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
108
|
export function gateNames() {
|
|
152
109
|
return Object.keys(GATES).sort();
|
|
153
110
|
}
|
package/src/hooks/resolve.mjs
CHANGED
|
@@ -14,14 +14,9 @@ import { access } from 'node:fs/promises';
|
|
|
14
14
|
import { constants } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
-
import { FORMATTABLE, GATES } from './gates.mjs';
|
|
17
|
+
import { FORMATTABLE, GATES, gateNames } from './gates.mjs';
|
|
18
18
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
19
19
|
|
|
20
|
-
/** Gate names the shared dispatcher knows, sorted. */
|
|
21
|
-
export function knownGates() {
|
|
22
|
-
return Object.keys(GATES).sort();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
20
|
/** Is this path an executable file? */
|
|
26
21
|
async function isExecutable(path) {
|
|
27
22
|
try {
|
|
@@ -203,7 +198,7 @@ export async function resolveGate(gate, { root, pkg, staged, commands, runner =
|
|
|
203
198
|
gate: name,
|
|
204
199
|
kind: 'unknown',
|
|
205
200
|
reason: `unknown gate kind "${gate.kind}"`,
|
|
206
|
-
remedy: `this is a bug in simt-dev;
|
|
201
|
+
remedy: `this is a bug in simt-dev; )}', ')}`,
|
|
207
202
|
};
|
|
208
203
|
}
|
|
209
204
|
|
package/src/hooks/shim.mjs
CHANGED
|
@@ -36,8 +36,11 @@ export const SHIM_MARKER = '# Generated by simt-dev. Do not edit.';
|
|
|
36
36
|
/** Events the shared dispatcher knows how to run. */
|
|
37
37
|
export const EVENTS = ['pre-commit', 'pre-push'];
|
|
38
38
|
|
|
39
|
-
/** Directory the shims are written to inside a repo. */
|
|
40
|
-
export const HOOKS_DIR = '.simt
|
|
39
|
+
/** Directory the shims are written to inside a repo, inside the state directory (ADR-122). */
|
|
40
|
+
export const HOOKS_DIR = '.simt/hooks';
|
|
41
|
+
|
|
42
|
+
/** The top-level shim directory that preceded the move into the state directory (ADR-122). */
|
|
43
|
+
export const HOOKS_DIR_LEGACY = '.simt-hooks';
|
|
41
44
|
|
|
42
45
|
/** Name of the git config key that points at the shim directory. */
|
|
43
46
|
export const HOOKS_PATH_KEY = 'core.hooksPath';
|
package/src/identity.mjs
CHANGED
|
@@ -257,4 +257,32 @@ export async function ignoreIdentity(root, journal = null) {
|
|
|
257
257
|
if (journal) await journaledWriteFile(root, journal, path, next);
|
|
258
258
|
else await writeFile(path, next);
|
|
259
259
|
return true;
|
|
260
|
-
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The `user.*` keys the repository's own config sets, which is the one place an
|
|
263
|
+
* identity can be overridden without a trace in the tracked tree.
|
|
264
|
+
*
|
|
265
|
+
* A repo-local `[user]` block wins over both the `git.config` include and the
|
|
266
|
+
* machine's own identity, so every commit made here inherits it silently - including
|
|
267
|
+
* the commits this tool makes on the reader's behalf (`slice finish`, `plan done`).
|
|
268
|
+
* That is not a hypothetical: 34 commits in this repository were authored as
|
|
269
|
+
* `t <t@t.t>` because `.git/config` carried that block and nothing reported it. The
|
|
270
|
+
* helper exists so `test/repo-identity.test.mjs` can fail loudly instead.
|
|
271
|
+
*
|
|
272
|
+
* `--local` rather than `--get`: the question is what THIS config adds, not what the
|
|
273
|
+
* resolved identity happens to be.
|
|
274
|
+
*
|
|
275
|
+
* @param {string} root
|
|
276
|
+
* @returns {Promise<{key: string, value: string}[]>}
|
|
277
|
+
*/
|
|
278
|
+
export async function localIdentityOverrides(root) {
|
|
279
|
+
const out = await git(root, ['config', '--local', '--get-regexp', '^user\\.']);
|
|
280
|
+
if (!out) return [];
|
|
281
|
+
return out
|
|
282
|
+
.split('\n')
|
|
283
|
+
.filter(Boolean)
|
|
284
|
+
.map((line) => {
|
|
285
|
+
const space = line.indexOf(' ');
|
|
286
|
+
return { key: line.slice(0, space), value: line.slice(space + 1) };
|
|
287
|
+
});
|
|
288
|
+
}
|
package/src/journal.mjs
CHANGED
|
@@ -16,10 +16,14 @@
|
|
|
16
16
|
// - The plan row says `.simt-dev/journal/`, which predates the STATE_DIR
|
|
17
17
|
// rename (the same stale spelling survives in bench/probes.mjs comments).
|
|
18
18
|
// The journal lives under STATE_DIR like every other per-repo state.
|
|
19
|
-
// - Nothing under `.simt/` is journaled
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// repair anything.
|
|
19
|
+
// - Nothing under `.simt/` is journaled, one named exception: the shim
|
|
20
|
+
// directory. The journal recording itself is recursion; the ledger, index
|
|
21
|
+
// and candidates are evidence and caches, and restoring those to an earlier
|
|
22
|
+
// byte-shape would corrupt R17 rather than repair anything. The shims are
|
|
23
|
+
// the opposite of that: git's routing points at them, and the undo that
|
|
24
|
+
// restores the routing must restore the shims with it (review 0003 Q22;
|
|
25
|
+
// ADR-122 moved them under STATE_DIR). `journaledWriteFile` performs the
|
|
26
|
+
// other writes unrecorded.
|
|
23
27
|
// - Only regular files. Symlinks carry no bytes for beforeBytes to hold, and
|
|
24
28
|
// the record shape is file bytes — pointer changes are reported by the
|
|
25
29
|
// command as before and are not undone. Removals are journaled as entries
|
|
@@ -39,12 +43,13 @@
|
|
|
39
43
|
|
|
40
44
|
import { execFile } from 'node:child_process';
|
|
41
45
|
import { createHash } from 'node:crypto';
|
|
42
|
-
import { chmod, cp, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
46
|
+
import { chmod, cp, mkdir, readFile, readdir, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises';
|
|
43
47
|
import { dirname, join, relative, sep } from 'node:path';
|
|
44
48
|
import { promisify } from 'node:util';
|
|
45
49
|
|
|
46
50
|
import { STATE_DIR } from './name.mjs';
|
|
47
51
|
import { LOCAL_DIR } from './state.mjs';
|
|
52
|
+
import { HOOKS_DIR } from './hooks/shim.mjs';
|
|
48
53
|
|
|
49
54
|
const exec = promisify(execFile);
|
|
50
55
|
|
|
@@ -129,8 +134,11 @@ export async function journaledWriteFile(root, acc, path, text, { mode } = {}) {
|
|
|
129
134
|
throw new Error(`journal refuses a write outside the repository: ${path}`);
|
|
130
135
|
}
|
|
131
136
|
const before = await readOrNull(path);
|
|
132
|
-
// Internal state is written, not recorded — see the module header
|
|
133
|
-
|
|
137
|
+
// Internal state is written, not recorded — see the module header, which
|
|
138
|
+
// names the shim directory as the one exception under STATE_DIR.
|
|
139
|
+
const r = rel(root, path);
|
|
140
|
+
const internal =
|
|
141
|
+
(r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
|
|
134
142
|
await mkdir(dirname(path), { recursive: true });
|
|
135
143
|
// tmp + rename, the way this module writes its own set files: a crash
|
|
136
144
|
// mid-write leaves the old bytes in place, never a half-written file
|
|
@@ -202,7 +210,9 @@ export async function journaledRemoveFile(root, acc, path) {
|
|
|
202
210
|
}
|
|
203
211
|
const before = await readOrNull(path);
|
|
204
212
|
if (before === null) return;
|
|
205
|
-
const
|
|
213
|
+
const r = rel(root, path);
|
|
214
|
+
const internal =
|
|
215
|
+
(r === STATE_DIR || r.startsWith(`${STATE_DIR}/`)) && r !== HOOKS_DIR && !r.startsWith(`${HOOKS_DIR}/`);
|
|
206
216
|
await unlink(path);
|
|
207
217
|
if (!internal) {
|
|
208
218
|
acc.push(fileEntry(rel(root, path), before, null));
|
|
@@ -324,6 +334,61 @@ function coalesceEntries(acc) {
|
|
|
324
334
|
});
|
|
325
335
|
}
|
|
326
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Move the sets that predate the move into `.simt/local/journal` (review 0003
|
|
339
|
+
* Q19's fallback directory) into the current directory, and take the legacy
|
|
340
|
+
* directory down when the move empties it. While the current directory is
|
|
341
|
+
* absent, `listJournalSets` falls back to the legacy one; once it exists, the
|
|
342
|
+
* two are disjoint and the legacy sets are invisible to `undo` — a repo that
|
|
343
|
+
* accumulated history under the old path loses it on its first write after the
|
|
344
|
+
* move (plan 0005 H3). `commitJournal` is the only writer, so the migration
|
|
345
|
+
* rides on it and heals on the next journal activity. Every legacy set
|
|
346
|
+
* predates every current set — the current directory is created by this
|
|
347
|
+
* writer — so the move cannot disturb recency order. A name present in both
|
|
348
|
+
* keeps both copies when the bytes differ: a set file is history, and a
|
|
349
|
+
* collision is not a reason to drop one of the two records. A crash mid-move
|
|
350
|
+
* splits the sets between the two directories; the move is idempotent, so the
|
|
351
|
+
* next call finishes it.
|
|
352
|
+
*/
|
|
353
|
+
async function migrateLegacyJournal(root) {
|
|
354
|
+
const legacy = join(root, JOURNAL_DIR_LEGACY);
|
|
355
|
+
let names;
|
|
356
|
+
try {
|
|
357
|
+
names = await readdir(legacy);
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (error.code !== 'ENOENT') throw error;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const dir = join(root, JOURNAL_DIR);
|
|
363
|
+
await mkdir(dir, { recursive: true });
|
|
364
|
+
for (const name of names) {
|
|
365
|
+
// Sets, `.undone.json` history and `.undoing.json` markers all end in
|
|
366
|
+
// `.json`; a stray file of any other kind keeps the directory in place.
|
|
367
|
+
if (!name.endsWith(SET_SUFFIX)) continue;
|
|
368
|
+
const from = join(legacy, name);
|
|
369
|
+
const to = join(dir, name);
|
|
370
|
+
try {
|
|
371
|
+
await stat(to);
|
|
372
|
+
} catch (error) {
|
|
373
|
+
if (error.code !== 'ENOENT') throw error;
|
|
374
|
+
await rename(from, to);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
// The name is taken: equal bytes are a duplicate of the same set (drop the
|
|
378
|
+
// legacy copy); differing bytes are a collision (keep both — the current
|
|
379
|
+
// copy shadows the legacy one in the listing).
|
|
380
|
+
const [a, b] = await Promise.all([readFile(from), readFile(to)]);
|
|
381
|
+
if (a.equals(b)) await unlink(from);
|
|
382
|
+
}
|
|
383
|
+
// `rmdir` fails when the directory is not empty, which is exactly the
|
|
384
|
+
// safety: a stray file the move does not take keeps the directory.
|
|
385
|
+
try {
|
|
386
|
+
await rmdir(legacy);
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (error.code !== 'ENOTEMPTY' && error.code !== 'EEXIST') throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
327
392
|
/**
|
|
328
393
|
* Persist a run's set. Writes nothing when the run changed nothing, so the
|
|
329
394
|
* last set on disk is always the last run that wrote.
|
|
@@ -331,6 +396,7 @@ function coalesceEntries(acc) {
|
|
|
331
396
|
* @returns {Promise<string|null>} the set id (file name), or null
|
|
332
397
|
*/
|
|
333
398
|
export async function commitJournal(root, command, acc, { at = Date.now() } = {}) {
|
|
399
|
+
await migrateLegacyJournal(root);
|
|
334
400
|
const entries = coalesceEntries(acc);
|
|
335
401
|
if (entries.length === 0) return null;
|
|
336
402
|
await ignoreJournal(root);
|
|
@@ -365,7 +431,10 @@ export async function journalHygiene(root) {
|
|
|
365
431
|
|
|
366
432
|
/** Set ids newest-first, undone sets excluded. */
|
|
367
433
|
export async function listJournalSets(root) {
|
|
368
|
-
// The pre-move directory is a fallback for one release (review 0003 Q19)
|
|
434
|
+
// The pre-move directory is a fallback for one release (review 0003 Q19);
|
|
435
|
+
// the sets in it move into the current directory on the next write
|
|
436
|
+
// (`migrateLegacyJournal`, plan 0005 H3), so the fallback only serves a
|
|
437
|
+
// repo that has not written since the move.
|
|
369
438
|
let names;
|
|
370
439
|
try {
|
|
371
440
|
names = await readdir(join(root, JOURNAL_DIR));
|