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/hooks/dispatcher.mjs
CHANGED
|
@@ -18,32 +18,353 @@
|
|
|
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';
|
|
22
|
-
import { readFile, stat } from 'node:fs/promises';
|
|
23
|
-
import {
|
|
21
|
+
import { execFile, spawn } from 'node:child_process';
|
|
22
|
+
import { mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises';
|
|
23
|
+
import { tmpdir } from 'node:os';
|
|
24
|
+
import { basename, join, matchesGlob, resolve, sep } from 'node:path';
|
|
24
25
|
import { promisify } from 'node:util';
|
|
25
26
|
|
|
26
27
|
import { GATES } from './gates.mjs';
|
|
27
28
|
import { resolveGate } from './resolve.mjs';
|
|
29
|
+
import { resolveNodeRunner } from '../ecosystem/node-runner.mjs';
|
|
28
30
|
import { CLI } from '../name.mjs';
|
|
29
31
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
30
32
|
import { assertVersionPin } from '../package-version.mjs';
|
|
31
|
-
import { recordRun } from '../ledger/index.mjs';
|
|
33
|
+
import { hasTestEvidence, readLedger, recordRun } from '../ledger/index.mjs';
|
|
32
34
|
import { renderFindings, scanStaged } from '../secrets.mjs';
|
|
33
35
|
|
|
34
36
|
const exec = promisify(execFile);
|
|
35
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
|
+
|
|
36
147
|
/** Gates that are known but not required for the event to pass. */
|
|
37
148
|
const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
|
|
38
149
|
|
|
150
|
+
/** The sha git writes for a ref that does not exist on the remote yet. */
|
|
151
|
+
const ZERO_SHA = '0'.repeat(40);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The lines git feeds a pre-push hook on stdin, one per ref being pushed:
|
|
155
|
+
* `<local ref> <local sha> <remote ref> <remote sha>`.
|
|
156
|
+
*
|
|
157
|
+
* Returns `{ refs, error }`. A line this parser cannot read is an ERROR, not a
|
|
158
|
+
* dropped line: a push that cannot be read is a push that cannot be classified,
|
|
159
|
+
* and the classifier's contract is to fail closed to the full suite.
|
|
160
|
+
*
|
|
161
|
+
* @param {string} text the raw stdin
|
|
162
|
+
* @returns {{refs: {ref: string, localSha: string, remoteSha: string}[] | null, error: string | null}}
|
|
163
|
+
*/
|
|
164
|
+
export function parsePushedRefs(text) {
|
|
165
|
+
const refs = [];
|
|
166
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
167
|
+
const trimmed = line.trim();
|
|
168
|
+
if (trimmed === '') continue;
|
|
169
|
+
const parts = trimmed.split(' ');
|
|
170
|
+
if (parts.length !== 4) return { refs: null, error: `unrecognized pre-push line: ${trimmed}` };
|
|
171
|
+
const [ref, localSha, , remoteSha] = parts;
|
|
172
|
+
if (!/^[0-9a-f]{40}$/.test(localSha) || !/^[0-9a-f]{40}$/.test(remoteSha)) {
|
|
173
|
+
return { refs: null, error: `unrecognized sha in pre-push line: ${trimmed}` };
|
|
174
|
+
}
|
|
175
|
+
refs.push({ ref, localSha, remoteSha });
|
|
176
|
+
}
|
|
177
|
+
return { refs, error: null };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Confirm a tag's commit is already accounted for, so the tag may ride along in a
|
|
182
|
+
* docs-only push (ADR-113). A tag carries no commits of its own in the release flow:
|
|
183
|
+
* it points at commits delivered (and tested) by a branch push. It is acceptable only
|
|
184
|
+
* when the commit it names is reachable from a remote-tracking ref (already pushed) or
|
|
185
|
+
* from a branch tip this same push is delivering. Any uncertainty - the tag does not
|
|
186
|
+
* resolve to a commit, the remote refs cannot be listed, no candidate is known, or the
|
|
187
|
+
* commit is unreachable from all of them - fails closed to the full suite.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} root the repository to ask git
|
|
190
|
+
* @param {string} tagSha the tag's local sha from the pre-push line
|
|
191
|
+
* @param {string[]} pushingTips branch tips this same push is delivering
|
|
192
|
+
* @returns {Promise<{ok: boolean, reason: string | null}>}
|
|
193
|
+
*/
|
|
194
|
+
async function tagCommitAccountedFor(root, tagSha, pushingTips) {
|
|
195
|
+
let commit;
|
|
196
|
+
try {
|
|
197
|
+
({ stdout: commit } = await exec('git', ['rev-parse', `${tagSha}^{commit}`], { cwd: root }));
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return { ok: false, reason: `the tag could not be resolved to a commit: ${String(error.stderr ?? error.message).trim()}` };
|
|
200
|
+
}
|
|
201
|
+
commit = commit.trim();
|
|
202
|
+
|
|
203
|
+
let remoteRefs;
|
|
204
|
+
try {
|
|
205
|
+
({ stdout: remoteRefs } = await exec('git', ['for-each-ref', 'refs/remotes/', '--format=%(refname)'], { cwd: root }));
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return { ok: false, reason: `the remote refs could not be listed: ${String(error.stderr ?? error.message).trim()}` };
|
|
208
|
+
}
|
|
209
|
+
const candidates = [
|
|
210
|
+
...remoteRefs.split('\n').map((s) => s.trim()).filter((s) => s !== ''),
|
|
211
|
+
...pushingTips,
|
|
212
|
+
];
|
|
213
|
+
if (candidates.length === 0) {
|
|
214
|
+
return { ok: false, reason: 'no remote refs are known and no branch is being pushed, so the tag’s commit cannot be confirmed as already delivered' };
|
|
215
|
+
}
|
|
216
|
+
for (const candidate of candidates) {
|
|
217
|
+
try {
|
|
218
|
+
await exec('git', ['merge-base', '--is-ancestor', commit, candidate], { cwd: root });
|
|
219
|
+
return { ok: true, reason: null };
|
|
220
|
+
} catch {
|
|
221
|
+
// not reachable from this ref; try the next candidate
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return { ok: false, reason: `the tag’s commit (${commit.slice(0, 7)}) is not reachable from any pushed ref; it may not have been tested` };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Decide whether a push carries only documentation, so the `test` gate may be
|
|
229
|
+
* skipped for it (ADR-113). Every uncertainty fails closed to `{ docsOnly: false }`
|
|
230
|
+
* with the reason the full suite runs: an empty ref list, a new branch (no remote
|
|
231
|
+
* sha to diff against), a tag whose commit is not confirmed already delivered, a
|
|
232
|
+
* commit count above the ceiling, a file outside the declared paths, or a git error.
|
|
233
|
+
*
|
|
234
|
+
* Branch refs are judged on a TREE diff (`remote..local`), so a force push that
|
|
235
|
+
* drops commits is judged on the trees it replaces, not on the commit count alone.
|
|
236
|
+
* Tag refs carry no commits of their own; each is accepted only when its commit is
|
|
237
|
+
* already accounted for (see `tagCommitAccountedFor`). Deletions push nothing.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} root the repository to ask git
|
|
240
|
+
* @param {{ref: string, localSha: string, remoteSha: string}[]} refs
|
|
241
|
+
* @param {{maxCommits: number, paths: string[]}} config the manifest's `gates.prePushSkipTest`
|
|
242
|
+
* @returns {Promise<{docsOnly: boolean, reason: string}>}
|
|
243
|
+
*/
|
|
244
|
+
export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
245
|
+
if (refs.length === 0) {
|
|
246
|
+
return { docsOnly: false, reason: 'the push carries no readable ref list; running the full suite' };
|
|
247
|
+
}
|
|
248
|
+
const deletions = refs.filter((r) => r.localSha === ZERO_SHA).length;
|
|
249
|
+
const branches = refs.filter((r) => r.localSha !== ZERO_SHA && !r.ref.startsWith('refs/tags/'));
|
|
250
|
+
const tags = refs.filter((r) => r.localSha !== ZERO_SHA && r.ref.startsWith('refs/tags/'));
|
|
251
|
+
|
|
252
|
+
let commits = 0;
|
|
253
|
+
const files = new Set();
|
|
254
|
+
for (const { ref, localSha, remoteSha } of branches) {
|
|
255
|
+
if (remoteSha === ZERO_SHA) {
|
|
256
|
+
return { docsOnly: false, reason: `a new branch is being pushed (${ref}); its history cannot be diffed against the remote` };
|
|
257
|
+
}
|
|
258
|
+
let count, names;
|
|
259
|
+
try {
|
|
260
|
+
({ stdout: count } = await exec('git', ['rev-list', '--count', `${remoteSha}..${localSha}`], { cwd: root }));
|
|
261
|
+
({ stdout: names } = await exec('git', ['diff', '--name-only', '-z', `${remoteSha}..${localSha}`], { cwd: root }));
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
|
|
264
|
+
}
|
|
265
|
+
commits += Number(count.trim());
|
|
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')) {
|
|
270
|
+
if (name !== '') files.add(name);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const pushingTips = branches.map((b) => b.localSha);
|
|
275
|
+
for (const { localSha } of tags) {
|
|
276
|
+
const verdict = await tagCommitAccountedFor(root, localSha, pushingTips);
|
|
277
|
+
if (!verdict.ok) return { docsOnly: false, reason: verdict.reason };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (commits > maxCommits) {
|
|
281
|
+
return { docsOnly: false, reason: `${commits} commits exceed the ${maxCommits}-commit ceiling for a docs-only push` };
|
|
282
|
+
}
|
|
283
|
+
const outside = [...files].filter((f) => !paths.some((p) => matchesGlob(f, p)));
|
|
284
|
+
if (outside.length > 0) {
|
|
285
|
+
return { docsOnly: false, reason: `${outside.length} file(s) are outside the docs-only paths: ${outside.slice(0, 5).join(', ')}` };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const parts = [];
|
|
289
|
+
if (commits > 0) parts.push(`${commits} commit(s) touching ${files.size} file(s), all inside the docs-only paths`);
|
|
290
|
+
if (tags.length > 0) parts.push(`${tags.length} tag(s) already delivered`);
|
|
291
|
+
if (deletions > 0) parts.push(`${deletions} ref deletion(s)`);
|
|
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('; ')}` };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The pre-push skip decision, from the manifest and the ref list git fed on stdin.
|
|
301
|
+
*
|
|
302
|
+
* The skip is OPT-IN: a repository without `gates.prePushSkipTest` runs the full
|
|
303
|
+
* suite on every push, because a control a repo did not declare is one it did not
|
|
304
|
+
* review. A misdeclaration (missing fields) also runs the suite - a skip the
|
|
305
|
+
* manifest cannot state precisely is a skip a reviewer cannot see.
|
|
306
|
+
*
|
|
307
|
+
* @returns {Promise<{reason: string, adr: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
308
|
+
*/
|
|
309
|
+
async function resolveDocsOnlySkip(root, manifest, pushed) {
|
|
310
|
+
if (pushed === undefined) return null;
|
|
311
|
+
const config = manifest?.gates?.prePushSkipTest;
|
|
312
|
+
if (typeof config?.maxCommits !== 'number' || !Array.isArray(config?.paths)) return null;
|
|
313
|
+
const { refs, error } = parsePushedRefs(pushed);
|
|
314
|
+
if (error !== null) {
|
|
315
|
+
return null; // the caller cannot be sure what is being pushed; the suite runs
|
|
316
|
+
}
|
|
317
|
+
const verdict = await classifyPush(root, refs, { maxCommits: config.maxCommits, paths: config.paths });
|
|
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
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
39
358
|
/**
|
|
40
359
|
* @param {string} event 'pre-commit' | 'pre-push'
|
|
41
360
|
* @param {object} ctx
|
|
42
361
|
* @param {string} ctx.root
|
|
43
362
|
* @param {object} ctx.manifest parsed simt-dev.json, or null
|
|
363
|
+
* @param {string} [ctx.pushed] the ref list git feeds pre-push on stdin; undefined when no
|
|
364
|
+
* push is being classified (every other event, a manual run with no stdin)
|
|
44
365
|
* @returns {Promise<{event: string, gates: object[], failed: boolean}>}
|
|
45
366
|
*/
|
|
46
|
-
export async function runGates(event, { root, manifest }) {
|
|
367
|
+
export async function runGates(event, { root, manifest, pushed }) {
|
|
47
368
|
const key = DEFAULT_EVENTS[event];
|
|
48
369
|
if (key === undefined) {
|
|
49
370
|
return { event, gates: [], failed: true, error: `unknown hook event ${event}` };
|
|
@@ -84,7 +405,92 @@ export async function runGates(event, { root, manifest }) {
|
|
|
84
405
|
};
|
|
85
406
|
}
|
|
86
407
|
|
|
408
|
+
// A pre-push gate runs against the COMMITTED tree, not the working one. Uncommitted
|
|
409
|
+
// work must not block the push of previous commits: a gate that fails on WIP the
|
|
410
|
+
// author has not committed is blocking a push it does not own. The worktree is
|
|
411
|
+
// disposable and shares the repository's object store, so `git worktree add` is cheap
|
|
412
|
+
// and needs no clone. The ledger and the version pin stay at the real root - the
|
|
413
|
+
// evidence is about this repository, not the throwaway checkout.
|
|
414
|
+
let runRoot = root;
|
|
415
|
+
let headWorktree = null;
|
|
416
|
+
let skipTest = null;
|
|
417
|
+
if (event === 'pre-push') {
|
|
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));
|
|
424
|
+
if (skipTest === null) {
|
|
425
|
+
const materialized = await materializeHeadWorktree(root);
|
|
426
|
+
if (materialized.path === null) {
|
|
427
|
+
return {
|
|
428
|
+
event,
|
|
429
|
+
gates: [
|
|
430
|
+
{
|
|
431
|
+
name: 'head',
|
|
432
|
+
status: 'failed',
|
|
433
|
+
detail: materialized.error,
|
|
434
|
+
remedy: 'commit the work, or resolve the git state so HEAD is a valid reference, then push again',
|
|
435
|
+
adr: null,
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
failed: true,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
runRoot = materialized.path;
|
|
442
|
+
headWorktree = materialized.path;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
return await runGatesAt(event, { root: runRoot, ledgerRoot: root, manifest, declared, key, skipTest });
|
|
448
|
+
} finally {
|
|
449
|
+
if (headWorktree !== null) await removeHeadWorktree(root, headWorktree);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Run the declared gates at `root`, recording evidence at `ledgerRoot`.
|
|
455
|
+
*
|
|
456
|
+
* For a pre-push run the two differ: the gates execute in a throwaway worktree at
|
|
457
|
+
* HEAD so uncommitted work cannot block the push, while the ledger line belongs to
|
|
458
|
+
* the repository the author is standing in. For every other event they are the same
|
|
459
|
+
* path, which is why this is one function with two names for the root rather than
|
|
460
|
+
* two functions.
|
|
461
|
+
*
|
|
462
|
+
* @param {string} event
|
|
463
|
+
* @param {object} ctx
|
|
464
|
+
* @param {string} ctx.root the tree the gates execute in
|
|
465
|
+
* @param {string} ctx.ledgerRoot the repository the evidence is recorded in
|
|
466
|
+
* @param {object} ctx.manifest
|
|
467
|
+
* @param {string[]} ctx.declared the gate names for this event
|
|
468
|
+
* @param {string} ctx.key the manifest key for this event
|
|
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
|
|
472
|
+
* @returns {Promise<{event: string, gates: object[], failed: boolean}>}
|
|
473
|
+
*/
|
|
474
|
+
async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, skipTest = null }) {
|
|
87
475
|
const pkg = await readPkg(root);
|
|
476
|
+
// Q31: the runner is committed evidence (lockfile, then `packageManager`),
|
|
477
|
+
// resolved once per hook run so every script gate spawns the same manager.
|
|
478
|
+
let runner = null;
|
|
479
|
+
if (pkg) {
|
|
480
|
+
const lockfiles = [];
|
|
481
|
+
for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
|
|
482
|
+
try {
|
|
483
|
+
await stat(join(root, file));
|
|
484
|
+
lockfiles.push(file);
|
|
485
|
+
} catch {
|
|
486
|
+
// Absent is the normal case.
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
runner = resolveNodeRunner({
|
|
490
|
+
lockfiles,
|
|
491
|
+
packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
|
|
492
|
+
}).id;
|
|
493
|
+
}
|
|
88
494
|
const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
|
|
89
495
|
const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
|
|
90
496
|
const commands = manifest?.gates?.commands;
|
|
@@ -94,6 +500,9 @@ export async function runGates(event, { root, manifest }) {
|
|
|
94
500
|
// mean re-reading the command string to find out where anything runs, and a manifest
|
|
95
501
|
// written by an older release would have to be rewritten to gain the capability.
|
|
96
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;
|
|
97
506
|
const results = [];
|
|
98
507
|
// What the format gate rewrote, so restage touches those files and nothing else.
|
|
99
508
|
let formatted = [];
|
|
@@ -111,7 +520,16 @@ export async function runGates(event, { root, manifest }) {
|
|
|
111
520
|
continue;
|
|
112
521
|
}
|
|
113
522
|
|
|
114
|
-
|
|
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.
|
|
527
|
+
if (skipTest !== null && name === 'test') {
|
|
528
|
+
results.push({ name, status: 'skipped', detail: skipTest.reason, adr: skipTest.adr, exitCode: null });
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const resolution = await resolveGate(gate, { root, pkg, staged, commands, runner });
|
|
115
533
|
if (!resolution.ok) {
|
|
116
534
|
results.push({
|
|
117
535
|
name,
|
|
@@ -130,13 +548,17 @@ export async function runGates(event, { root, manifest }) {
|
|
|
130
548
|
// declared command for this name takes the shell path below instead.
|
|
131
549
|
if (resolution.builtin !== undefined) {
|
|
132
550
|
const startedBuiltin = Date.now();
|
|
133
|
-
|
|
551
|
+
// The manifest's per-builtin options, where a builtin has any: the secret
|
|
552
|
+
// scan takes its ignore globs from gates.secretScan (review 0003 Q24).
|
|
553
|
+
const outcome = await runBuiltinGate(resolution.builtin, { root, staged, options: manifest?.gates?.secretScan });
|
|
134
554
|
try {
|
|
135
|
-
await recordRun(
|
|
555
|
+
await recordRun(ledgerRoot, {
|
|
136
556
|
cmd: `builtin:${resolution.builtin}`,
|
|
137
557
|
exit: outcome.ok ? 0 : 1,
|
|
138
558
|
ms: Date.now() - startedBuiltin,
|
|
139
559
|
output: outcome.output,
|
|
560
|
+
gate: name,
|
|
561
|
+
kind: 'builtin',
|
|
140
562
|
});
|
|
141
563
|
} catch {
|
|
142
564
|
// Recorded by absence: the next evidence read for this tree has no line.
|
|
@@ -213,26 +635,41 @@ export async function runGates(event, { root, manifest }) {
|
|
|
213
635
|
cwd = resolved;
|
|
214
636
|
}
|
|
215
637
|
|
|
638
|
+
const timeoutMs = gateTimeoutMs(timeouts, name);
|
|
216
639
|
const started = Date.now();
|
|
217
|
-
const outcome = await runGate(resolution, cwd);
|
|
640
|
+
const outcome = await runGate(resolution, cwd, timeoutMs);
|
|
218
641
|
// The ledger is not the gate. A tree that cannot be named leaves no line, and the
|
|
219
642
|
// claim check then finds nothing, which is the direction that cannot invent a run.
|
|
220
643
|
try {
|
|
221
644
|
const cmd = typeof resolution.command === 'string' ? resolution.command : resolution.argv.join(' ');
|
|
222
|
-
|
|
645
|
+
// The evidence claim needs the gate and its kind: a passing secret-scan is
|
|
646
|
+
// not a passing test run (review 0003 Q18). Coverage proves tests; anything
|
|
647
|
+
// else keeps its own kind or the gate name.
|
|
648
|
+
const kind =
|
|
649
|
+
name === 'test' || name === 'coverage' ? 'test' : name === 'lint' ? 'lint' : name === 'format' ? 'format' : (gate.kind ?? name);
|
|
650
|
+
await recordRun(ledgerRoot, {
|
|
223
651
|
cmd,
|
|
224
652
|
exit: outcome.code,
|
|
225
653
|
ms: Date.now() - started,
|
|
226
654
|
output: `${outcome.stdout ?? ''}\n${outcome.stderr ?? ''}`,
|
|
655
|
+
gate: name,
|
|
656
|
+
kind,
|
|
227
657
|
});
|
|
228
658
|
} catch {
|
|
229
659
|
// Recorded by absence: the next evidence read for this tree has no line.
|
|
230
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.
|
|
231
663
|
results.push({
|
|
232
664
|
name,
|
|
233
665
|
status: outcome.code === 0 ? 'passed' : 'failed',
|
|
234
|
-
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}`}`,
|
|
235
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
|
+
: {}),
|
|
236
673
|
adr: gate.adr,
|
|
237
674
|
exitCode: outcome.code,
|
|
238
675
|
});
|
|
@@ -273,6 +710,54 @@ export async function runGates(event, { root, manifest }) {
|
|
|
273
710
|
return { event, gates: results, failed: results.some((r) => r.status === 'failed') };
|
|
274
711
|
}
|
|
275
712
|
|
|
713
|
+
/**
|
|
714
|
+
* Materialize a detached worktree at HEAD so the pre-push gates run against the
|
|
715
|
+
* committed tree, not the working one. The worktree shares the repository's object
|
|
716
|
+
* store, so `git worktree add` is cheap and needs no clone. `node_modules` is
|
|
717
|
+
* symlinked in when the real root has one, because a script gate resolves its runner
|
|
718
|
+
* and binaries from there and a fresh worktree does not carry it.
|
|
719
|
+
*
|
|
720
|
+
* Fails closed: an unborn HEAD (no commits) or a git error returns `{ path: null }`
|
|
721
|
+
* with the reason, and the caller turns that into a gate failure rather than running
|
|
722
|
+
* against a tree it cannot name.
|
|
723
|
+
*
|
|
724
|
+
* @param {string} root the real repository root
|
|
725
|
+
* @returns {Promise<{path: string, error?: never}|{path: null, error: string}>}
|
|
726
|
+
*/
|
|
727
|
+
async function materializeHeadWorktree(root) {
|
|
728
|
+
const path = await mkdtemp(join(tmpdir(), 'head-gate-'));
|
|
729
|
+
try {
|
|
730
|
+
await exec('git', ['worktree', 'add', '--detach', path, 'HEAD'], { cwd: root });
|
|
731
|
+
} catch (error) {
|
|
732
|
+
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
733
|
+
return { path: null, error: `git worktree add failed: ${String(error.stderr ?? error.message).trim()}` };
|
|
734
|
+
}
|
|
735
|
+
const nodeModules = join(root, 'node_modules');
|
|
736
|
+
try {
|
|
737
|
+
await symlink(nodeModules, join(path, 'node_modules'));
|
|
738
|
+
} catch {
|
|
739
|
+
// Absent node_modules is the normal case for a repo with no install step; a
|
|
740
|
+
// dangling link is harmless, and `npm run` does not need it to exist.
|
|
741
|
+
}
|
|
742
|
+
return { path };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Remove a pre-push worktree. `--force` because a gate may have left it dirty (a
|
|
747
|
+
* formatter, a test that writes fixtures); the worktree is disposable, the real tree
|
|
748
|
+
* is not. Falls back to `rm -rf` plus a prune when git cannot remove it (a lock, a
|
|
749
|
+
* path in a state git does not expect), so a cleanup failure never leaks the tree.
|
|
750
|
+
*
|
|
751
|
+
* @param {string} root the real repository root
|
|
752
|
+
* @param {string} path the worktree to remove
|
|
753
|
+
*/
|
|
754
|
+
async function removeHeadWorktree(root, path) {
|
|
755
|
+
await exec('git', ['worktree', 'remove', '--force', path], { cwd: root }).catch(async () => {
|
|
756
|
+
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
757
|
+
await exec('git', ['worktree', 'prune'], { cwd: root }).catch(() => {});
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
276
761
|
/**
|
|
277
762
|
* The lines that report one event's gate run: the event, what was declared, every
|
|
278
763
|
* gate with its disposition, its reason and its remedy, and the verdict.
|
|
@@ -298,13 +783,8 @@ export function renderGateReport(event, declared, result) {
|
|
|
298
783
|
return lines;
|
|
299
784
|
}
|
|
300
785
|
|
|
301
|
-
async function run(argv, cwd) {
|
|
302
|
-
|
|
303
|
-
const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
304
|
-
return { code: 0, stdout, stderr };
|
|
305
|
-
} catch (error) {
|
|
306
|
-
return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
|
|
307
|
-
}
|
|
786
|
+
async function run(argv, cwd, env, timeoutMs) {
|
|
787
|
+
return await runBounded(argv, { cwd, env, timeoutMs });
|
|
308
788
|
}
|
|
309
789
|
|
|
310
790
|
/** The built-in gates this dispatcher can run, by name. */
|
|
@@ -320,7 +800,7 @@ const BUILTIN_REPORT_MAX = 12;
|
|
|
320
800
|
* none of them can degrade into a pass, which is the same fail-closed contract
|
|
321
801
|
* an unresolvable script gate has.
|
|
322
802
|
*/
|
|
323
|
-
async function runBuiltinGate(name, { root, staged }) {
|
|
803
|
+
async function runBuiltinGate(name, { root, staged, options }) {
|
|
324
804
|
const scan = BUILTIN_GATES.get(name);
|
|
325
805
|
if (scan === undefined) {
|
|
326
806
|
return {
|
|
@@ -333,7 +813,7 @@ async function runBuiltinGate(name, { root, staged }) {
|
|
|
333
813
|
if (staged.length === 0) {
|
|
334
814
|
return { ok: true, detail: 'nothing staged; no added line to scan', output: '' };
|
|
335
815
|
}
|
|
336
|
-
const result = await scan(root);
|
|
816
|
+
const result = await scan(root, options ?? {});
|
|
337
817
|
if (result.error !== null) {
|
|
338
818
|
return {
|
|
339
819
|
ok: false,
|
|
@@ -375,20 +855,15 @@ async function runBuiltinGate(name, { root, staged }) {
|
|
|
375
855
|
* that the two sources stay distinguishable, which is why this is a separate branch
|
|
376
856
|
* rather than a flag on `run`.
|
|
377
857
|
*/
|
|
378
|
-
async function runGate(resolution, cwd) {
|
|
858
|
+
async function runGate(resolution, cwd, timeoutMs) {
|
|
859
|
+
// The push override is for the push itself, not for the gates the hook runs.
|
|
860
|
+
// A test that asserts a push is denied sees the override and fails.
|
|
861
|
+
const env = { ...process.env };
|
|
862
|
+
delete env.SIMT_ALLOW;
|
|
379
863
|
if (resolution.shell === true && typeof resolution.command === 'string') {
|
|
380
|
-
|
|
381
|
-
const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
382
|
-
return { code: 0, stdout, stderr };
|
|
383
|
-
} catch (error) {
|
|
384
|
-
return {
|
|
385
|
-
code: typeof error.code === 'number' ? error.code : 1,
|
|
386
|
-
stdout: error.stdout ?? '',
|
|
387
|
-
stderr: error.stderr ?? String(error.message ?? error),
|
|
388
|
-
};
|
|
389
|
-
}
|
|
864
|
+
return await run(['sh', '-c', resolution.command], cwd, env, timeoutMs);
|
|
390
865
|
}
|
|
391
|
-
return run(resolution.argv, cwd);
|
|
866
|
+
return await run(resolution.argv, cwd, env, timeoutMs);
|
|
392
867
|
}
|
|
393
868
|
|
|
394
869
|
async function readPkg(root) {
|
|
@@ -402,8 +877,10 @@ async function readPkg(root) {
|
|
|
402
877
|
/** Staged paths, or [] when git cannot answer (an unborn branch, for instance). */
|
|
403
878
|
export async function stagedFiles(root) {
|
|
404
879
|
try {
|
|
405
|
-
|
|
406
|
-
|
|
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);
|
|
407
884
|
} catch {
|
|
408
885
|
return [];
|
|
409
886
|
}
|
|
@@ -419,8 +896,8 @@ export async function stagedFiles(root) {
|
|
|
419
896
|
*/
|
|
420
897
|
async function unstagedFiles(root) {
|
|
421
898
|
try {
|
|
422
|
-
const { stdout } = await exec('git', ['diff', '--name-only'], { cwd: root });
|
|
423
|
-
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));
|
|
424
901
|
} catch {
|
|
425
902
|
return new Set();
|
|
426
903
|
}
|