simt-dev 0.6.2 → 0.7.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 +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.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-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- 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/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- 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 +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- 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 +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- 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 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- 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 +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- 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 +21 -14
- package/src/projection/manifest.mjs +82 -21
- 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 +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/hooks/dispatcher.mjs
CHANGED
|
@@ -19,30 +19,200 @@
|
|
|
19
19
|
// its reason, so "passed" can never be mistaken for "only some of it passed".
|
|
20
20
|
|
|
21
21
|
import { execFile } from 'node:child_process';
|
|
22
|
-
import { readFile, stat } from 'node:fs/promises';
|
|
23
|
-
import {
|
|
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
33
|
import { recordRun } from '../ledger/index.mjs';
|
|
34
|
+
import { renderFindings, scanStaged } from '../secrets.mjs';
|
|
32
35
|
|
|
33
36
|
const exec = promisify(execFile);
|
|
34
37
|
|
|
35
38
|
/** Gates that are known but not required for the event to pass. */
|
|
36
39
|
const DEFAULT_EVENTS = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
|
|
37
40
|
|
|
41
|
+
/** The sha git writes for a ref that does not exist on the remote yet. */
|
|
42
|
+
const ZERO_SHA = '0'.repeat(40);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The lines git feeds a pre-push hook on stdin, one per ref being pushed:
|
|
46
|
+
* `<local ref> <local sha> <remote ref> <remote sha>`.
|
|
47
|
+
*
|
|
48
|
+
* Returns `{ refs, error }`. A line this parser cannot read is an ERROR, not a
|
|
49
|
+
* dropped line: a push that cannot be read is a push that cannot be classified,
|
|
50
|
+
* and the classifier's contract is to fail closed to the full suite.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} text the raw stdin
|
|
53
|
+
* @returns {{refs: {ref: string, localSha: string, remoteSha: string}[] | null, error: string | null}}
|
|
54
|
+
*/
|
|
55
|
+
export function parsePushedRefs(text) {
|
|
56
|
+
const refs = [];
|
|
57
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
58
|
+
const trimmed = line.trim();
|
|
59
|
+
if (trimmed === '') continue;
|
|
60
|
+
const parts = trimmed.split(' ');
|
|
61
|
+
if (parts.length !== 4) return { refs: null, error: `unrecognized pre-push line: ${trimmed}` };
|
|
62
|
+
const [ref, localSha, , remoteSha] = parts;
|
|
63
|
+
if (!/^[0-9a-f]{40}$/.test(localSha) || !/^[0-9a-f]{40}$/.test(remoteSha)) {
|
|
64
|
+
return { refs: null, error: `unrecognized sha in pre-push line: ${trimmed}` };
|
|
65
|
+
}
|
|
66
|
+
refs.push({ ref, localSha, remoteSha });
|
|
67
|
+
}
|
|
68
|
+
return { refs, error: null };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Confirm a tag's commit is already accounted for, so the tag may ride along in a
|
|
73
|
+
* docs-only push (ADR-113). A tag carries no commits of its own in the release flow:
|
|
74
|
+
* it points at commits delivered (and tested) by a branch push. It is acceptable only
|
|
75
|
+
* when the commit it names is reachable from a remote-tracking ref (already pushed) or
|
|
76
|
+
* from a branch tip this same push is delivering. Any uncertainty - the tag does not
|
|
77
|
+
* resolve to a commit, the remote refs cannot be listed, no candidate is known, or the
|
|
78
|
+
* commit is unreachable from all of them - fails closed to the full suite.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} root the repository to ask git
|
|
81
|
+
* @param {string} tagSha the tag's local sha from the pre-push line
|
|
82
|
+
* @param {string[]} pushingTips branch tips this same push is delivering
|
|
83
|
+
* @returns {Promise<{ok: boolean, reason: string | null}>}
|
|
84
|
+
*/
|
|
85
|
+
async function tagCommitAccountedFor(root, tagSha, pushingTips) {
|
|
86
|
+
let commit;
|
|
87
|
+
try {
|
|
88
|
+
({ stdout: commit } = await exec('git', ['rev-parse', `${tagSha}^{commit}`], { cwd: root }));
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { ok: false, reason: `the tag could not be resolved to a commit: ${String(error.stderr ?? error.message).trim()}` };
|
|
91
|
+
}
|
|
92
|
+
commit = commit.trim();
|
|
93
|
+
|
|
94
|
+
let remoteRefs;
|
|
95
|
+
try {
|
|
96
|
+
({ stdout: remoteRefs } = await exec('git', ['for-each-ref', 'refs/remotes/', '--format=%(refname)'], { cwd: root }));
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { ok: false, reason: `the remote refs could not be listed: ${String(error.stderr ?? error.message).trim()}` };
|
|
99
|
+
}
|
|
100
|
+
const candidates = [
|
|
101
|
+
...remoteRefs.split('\n').map((s) => s.trim()).filter((s) => s !== ''),
|
|
102
|
+
...pushingTips,
|
|
103
|
+
];
|
|
104
|
+
if (candidates.length === 0) {
|
|
105
|
+
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' };
|
|
106
|
+
}
|
|
107
|
+
for (const candidate of candidates) {
|
|
108
|
+
try {
|
|
109
|
+
await exec('git', ['merge-base', '--is-ancestor', commit, candidate], { cwd: root });
|
|
110
|
+
return { ok: true, reason: null };
|
|
111
|
+
} catch {
|
|
112
|
+
// not reachable from this ref; try the next candidate
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
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` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Decide whether a push carries only documentation, so the `test` gate may be
|
|
120
|
+
* skipped for it (ADR-113). Every uncertainty fails closed to `{ docsOnly: false }`
|
|
121
|
+
* with the reason the full suite runs: an empty ref list, a new branch (no remote
|
|
122
|
+
* sha to diff against), a tag whose commit is not confirmed already delivered, a
|
|
123
|
+
* commit count above the ceiling, a file outside the declared paths, or a git error.
|
|
124
|
+
*
|
|
125
|
+
* Branch refs are judged on a TREE diff (`remote..local`), so a force push that
|
|
126
|
+
* drops commits is judged on the trees it replaces, not on the commit count alone.
|
|
127
|
+
* Tag refs carry no commits of their own; each is accepted only when its commit is
|
|
128
|
+
* already accounted for (see `tagCommitAccountedFor`). Deletions push nothing.
|
|
129
|
+
*
|
|
130
|
+
* @param {string} root the repository to ask git
|
|
131
|
+
* @param {{ref: string, localSha: string, remoteSha: string}[]} refs
|
|
132
|
+
* @param {{maxCommits: number, paths: string[]}} config the manifest's `gates.prePushSkipTest`
|
|
133
|
+
* @returns {Promise<{docsOnly: boolean, reason: string}>}
|
|
134
|
+
*/
|
|
135
|
+
export async function classifyPush(root, refs, { maxCommits, paths }) {
|
|
136
|
+
if (refs.length === 0) {
|
|
137
|
+
return { docsOnly: false, reason: 'the push carries no readable ref list; running the full suite' };
|
|
138
|
+
}
|
|
139
|
+
const deletions = refs.filter((r) => r.localSha === ZERO_SHA).length;
|
|
140
|
+
const branches = refs.filter((r) => r.localSha !== ZERO_SHA && !r.ref.startsWith('refs/tags/'));
|
|
141
|
+
const tags = refs.filter((r) => r.localSha !== ZERO_SHA && r.ref.startsWith('refs/tags/'));
|
|
142
|
+
|
|
143
|
+
let commits = 0;
|
|
144
|
+
const files = new Set();
|
|
145
|
+
for (const { ref, localSha, remoteSha } of branches) {
|
|
146
|
+
if (remoteSha === ZERO_SHA) {
|
|
147
|
+
return { docsOnly: false, reason: `a new branch is being pushed (${ref}); its history cannot be diffed against the remote` };
|
|
148
|
+
}
|
|
149
|
+
let count, names;
|
|
150
|
+
try {
|
|
151
|
+
({ 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 }));
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return { docsOnly: false, reason: `the push could not be diffed: ${String(error.stderr ?? error.message).trim()}` };
|
|
155
|
+
}
|
|
156
|
+
commits += Number(count.trim());
|
|
157
|
+
for (const f of names.split('\n')) {
|
|
158
|
+
const name = f.trim();
|
|
159
|
+
if (name !== '') files.add(name);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const pushingTips = branches.map((b) => b.localSha);
|
|
164
|
+
for (const { localSha } of tags) {
|
|
165
|
+
const verdict = await tagCommitAccountedFor(root, localSha, pushingTips);
|
|
166
|
+
if (!verdict.ok) return { docsOnly: false, reason: verdict.reason };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (commits > maxCommits) {
|
|
170
|
+
return { docsOnly: false, reason: `${commits} commits exceed the ${maxCommits}-commit ceiling for a docs-only push` };
|
|
171
|
+
}
|
|
172
|
+
const outside = [...files].filter((f) => !paths.some((p) => matchesGlob(f, p)));
|
|
173
|
+
if (outside.length > 0) {
|
|
174
|
+
return { docsOnly: false, reason: `${outside.length} file(s) are outside the docs-only paths: ${outside.slice(0, 5).join(', ')}` };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const parts = [];
|
|
178
|
+
if (commits > 0) parts.push(`${commits} commit(s) touching ${files.size} file(s), all inside the docs-only paths`);
|
|
179
|
+
if (tags.length > 0) parts.push(`${tags.length} tag(s) already delivered`);
|
|
180
|
+
if (deletions > 0) parts.push(`${deletions} ref deletion(s)`);
|
|
181
|
+
return { docsOnly: true, reason: parts.length > 0 ? parts.join('; ') : 'no commits carried' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The pre-push skip decision, from the manifest and the ref list git fed on stdin.
|
|
186
|
+
*
|
|
187
|
+
* The skip is OPT-IN: a repository without `gates.prePushSkipTest` runs the full
|
|
188
|
+
* suite on every push, because a control a repo did not declare is one it did not
|
|
189
|
+
* review. A misdeclaration (missing fields) also runs the suite - a skip the
|
|
190
|
+
* manifest cannot state precisely is a skip a reviewer cannot see.
|
|
191
|
+
*
|
|
192
|
+
* @returns {Promise<{reason: string} | null>} null runs the suite; a reason skips the `test` gate
|
|
193
|
+
*/
|
|
194
|
+
async function resolveDocsOnlySkip(root, manifest, pushed) {
|
|
195
|
+
if (pushed === undefined) return null;
|
|
196
|
+
const config = manifest?.gates?.prePushSkipTest;
|
|
197
|
+
if (typeof config?.maxCommits !== 'number' || !Array.isArray(config?.paths)) return null;
|
|
198
|
+
const { refs, error } = parsePushedRefs(pushed);
|
|
199
|
+
if (error !== null) {
|
|
200
|
+
return null; // the caller cannot be sure what is being pushed; the suite runs
|
|
201
|
+
}
|
|
202
|
+
const verdict = await classifyPush(root, refs, { maxCommits: config.maxCommits, paths: config.paths });
|
|
203
|
+
return verdict.docsOnly ? { reason: verdict.reason } : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
38
206
|
/**
|
|
39
207
|
* @param {string} event 'pre-commit' | 'pre-push'
|
|
40
208
|
* @param {object} ctx
|
|
41
209
|
* @param {string} ctx.root
|
|
42
210
|
* @param {object} ctx.manifest parsed simt-dev.json, or null
|
|
211
|
+
* @param {string} [ctx.pushed] the ref list git feeds pre-push on stdin; undefined when no
|
|
212
|
+
* push is being classified (every other event, a manual run with no stdin)
|
|
43
213
|
* @returns {Promise<{event: string, gates: object[], failed: boolean}>}
|
|
44
214
|
*/
|
|
45
|
-
export async function runGates(event, { root, manifest }) {
|
|
215
|
+
export async function runGates(event, { root, manifest, pushed }) {
|
|
46
216
|
const key = DEFAULT_EVENTS[event];
|
|
47
217
|
if (key === undefined) {
|
|
48
218
|
return { event, gates: [], failed: true, error: `unknown hook event ${event}` };
|
|
@@ -83,7 +253,89 @@ export async function runGates(event, { root, manifest }) {
|
|
|
83
253
|
};
|
|
84
254
|
}
|
|
85
255
|
|
|
256
|
+
// A pre-push gate runs against the COMMITTED tree, not the working one. Uncommitted
|
|
257
|
+
// work must not block the push of previous commits: a gate that fails on WIP the
|
|
258
|
+
// author has not committed is blocking a push it does not own. The worktree is
|
|
259
|
+
// disposable and shares the repository's object store, so `git worktree add` is cheap
|
|
260
|
+
// and needs no clone. The ledger and the version pin stay at the real root - the
|
|
261
|
+
// evidence is about this repository, not the throwaway checkout.
|
|
262
|
+
let runRoot = root;
|
|
263
|
+
let headWorktree = null;
|
|
264
|
+
let skipTest = null;
|
|
265
|
+
if (event === 'pre-push') {
|
|
266
|
+
// Decided before the worktree exists: a push that carries only documentation
|
|
267
|
+
// skips the `test` gate (ADR-113), and a push that earns the skip needs no
|
|
268
|
+
// tree for a gate that will not run.
|
|
269
|
+
skipTest = await resolveDocsOnlySkip(root, manifest, pushed);
|
|
270
|
+
if (skipTest === null) {
|
|
271
|
+
const materialized = await materializeHeadWorktree(root);
|
|
272
|
+
if (materialized.path === null) {
|
|
273
|
+
return {
|
|
274
|
+
event,
|
|
275
|
+
gates: [
|
|
276
|
+
{
|
|
277
|
+
name: 'head',
|
|
278
|
+
status: 'failed',
|
|
279
|
+
detail: materialized.error,
|
|
280
|
+
remedy: 'commit the work, or resolve the git state so HEAD is a valid reference, then push again',
|
|
281
|
+
adr: null,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
failed: true,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
runRoot = materialized.path;
|
|
288
|
+
headWorktree = materialized.path;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
return await runGatesAt(event, { root: runRoot, ledgerRoot: root, manifest, declared, key, skipTest });
|
|
294
|
+
} finally {
|
|
295
|
+
if (headWorktree !== null) await removeHeadWorktree(root, headWorktree);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Run the declared gates at `root`, recording evidence at `ledgerRoot`.
|
|
301
|
+
*
|
|
302
|
+
* For a pre-push run the two differ: the gates execute in a throwaway worktree at
|
|
303
|
+
* HEAD so uncommitted work cannot block the push, while the ledger line belongs to
|
|
304
|
+
* the repository the author is standing in. For every other event they are the same
|
|
305
|
+
* path, which is why this is one function with two names for the root rather than
|
|
306
|
+
* two functions.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} event
|
|
309
|
+
* @param {object} ctx
|
|
310
|
+
* @param {string} ctx.root the tree the gates execute in
|
|
311
|
+
* @param {string} ctx.ledgerRoot the repository the evidence is recorded in
|
|
312
|
+
* @param {object} ctx.manifest
|
|
313
|
+
* @param {string[]} ctx.declared the gate names for this event
|
|
314
|
+
* @param {string} ctx.key the manifest key for this event
|
|
315
|
+
* @param {{reason: string} | null} [ctx.skipTest] set when a docs-only push skips the
|
|
316
|
+
* `test` gate (ADR-113); the reason is what the report prints in its place
|
|
317
|
+
* @returns {Promise<{event: string, gates: object[], failed: boolean}>}
|
|
318
|
+
*/
|
|
319
|
+
async function runGatesAt(event, { root, ledgerRoot, manifest, declared, key, skipTest = null }) {
|
|
86
320
|
const pkg = await readPkg(root);
|
|
321
|
+
// Q31: the runner is committed evidence (lockfile, then `packageManager`),
|
|
322
|
+
// resolved once per hook run so every script gate spawns the same manager.
|
|
323
|
+
let runner = null;
|
|
324
|
+
if (pkg) {
|
|
325
|
+
const lockfiles = [];
|
|
326
|
+
for (const file of ['pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb', 'package-lock.json']) {
|
|
327
|
+
try {
|
|
328
|
+
await stat(join(root, file));
|
|
329
|
+
lockfiles.push(file);
|
|
330
|
+
} catch {
|
|
331
|
+
// Absent is the normal case.
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
runner = resolveNodeRunner({
|
|
335
|
+
lockfiles,
|
|
336
|
+
packageManager: typeof pkg.packageManager === 'string' ? pkg.packageManager : null,
|
|
337
|
+
}).id;
|
|
338
|
+
}
|
|
87
339
|
const staged = event === 'pre-commit' ? await stagedFiles(root) : [];
|
|
88
340
|
const unstaged = event === 'pre-commit' ? await unstagedFiles(root) : new Set();
|
|
89
341
|
const commands = manifest?.gates?.commands;
|
|
@@ -110,7 +362,15 @@ export async function runGates(event, { root, manifest }) {
|
|
|
110
362
|
continue;
|
|
111
363
|
}
|
|
112
364
|
|
|
113
|
-
|
|
365
|
+
// The one gate a docs-only push does not run (ADR-113). It is reported, not
|
|
366
|
+
// dropped: a skip without a printed reason would let "passed" mean "the suite
|
|
367
|
+
// never ran", which is the exact confusion the report exists to prevent.
|
|
368
|
+
if (skipTest !== null && name === 'test') {
|
|
369
|
+
results.push({ name, status: 'skipped', detail: `docs-only push: ${skipTest.reason}`, adr: 'ADR-113', exitCode: null });
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const resolution = await resolveGate(gate, { root, pkg, staged, commands, runner });
|
|
114
374
|
if (!resolution.ok) {
|
|
115
375
|
results.push({
|
|
116
376
|
name,
|
|
@@ -121,6 +381,41 @@ export async function runGates(event, { root, manifest }) {
|
|
|
121
381
|
});
|
|
122
382
|
continue;
|
|
123
383
|
}
|
|
384
|
+
|
|
385
|
+
// A built-in gate runs here, in this process: no script, no command, no
|
|
386
|
+
// tool on disk (ADR-100). It gets the same ledger line and the same
|
|
387
|
+
// fail-closed reporting as every other gate; only the source of the bytes
|
|
388
|
+
// it runs over differs. Keyed on the RESOLUTION rather than the gate, so a
|
|
389
|
+
// declared command for this name takes the shell path below instead.
|
|
390
|
+
if (resolution.builtin !== undefined) {
|
|
391
|
+
const startedBuiltin = Date.now();
|
|
392
|
+
// The manifest's per-builtin options, where a builtin has any: the secret
|
|
393
|
+
// scan takes its ignore globs from gates.secretScan (review 0003 Q24).
|
|
394
|
+
const outcome = await runBuiltinGate(resolution.builtin, { root, staged, options: manifest?.gates?.secretScan });
|
|
395
|
+
try {
|
|
396
|
+
await recordRun(ledgerRoot, {
|
|
397
|
+
cmd: `builtin:${resolution.builtin}`,
|
|
398
|
+
exit: outcome.ok ? 0 : 1,
|
|
399
|
+
ms: Date.now() - startedBuiltin,
|
|
400
|
+
output: outcome.output,
|
|
401
|
+
gate: name,
|
|
402
|
+
kind: 'builtin',
|
|
403
|
+
});
|
|
404
|
+
} catch {
|
|
405
|
+
// Recorded by absence: the next evidence read for this tree has no line.
|
|
406
|
+
}
|
|
407
|
+
results.push({
|
|
408
|
+
name,
|
|
409
|
+
status: outcome.ok ? 'passed' : 'failed',
|
|
410
|
+
detail: outcome.detail,
|
|
411
|
+
output: outcome.ok ? '' : outcome.output,
|
|
412
|
+
...(outcome.remedy !== undefined ? { remedy: outcome.remedy } : {}),
|
|
413
|
+
adr: gate.adr,
|
|
414
|
+
exitCode: outcome.ok ? 0 : 1,
|
|
415
|
+
});
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
124
419
|
if (resolution.argv === null) {
|
|
125
420
|
results.push({ name, status: 'skipped', detail: resolution.detail, adr: gate.adr, exitCode: null });
|
|
126
421
|
continue;
|
|
@@ -187,11 +482,18 @@ export async function runGates(event, { root, manifest }) {
|
|
|
187
482
|
// claim check then finds nothing, which is the direction that cannot invent a run.
|
|
188
483
|
try {
|
|
189
484
|
const cmd = typeof resolution.command === 'string' ? resolution.command : resolution.argv.join(' ');
|
|
190
|
-
|
|
485
|
+
// The evidence claim needs the gate and its kind: a passing secret-scan is
|
|
486
|
+
// not a passing test run (review 0003 Q18). Coverage proves tests; anything
|
|
487
|
+
// else keeps its own kind or the gate name.
|
|
488
|
+
const kind =
|
|
489
|
+
name === 'test' || name === 'coverage' ? 'test' : name === 'lint' ? 'lint' : name === 'format' ? 'format' : (gate.kind ?? name);
|
|
490
|
+
await recordRun(ledgerRoot, {
|
|
191
491
|
cmd,
|
|
192
492
|
exit: outcome.code,
|
|
193
493
|
ms: Date.now() - started,
|
|
194
494
|
output: `${outcome.stdout ?? ''}\n${outcome.stderr ?? ''}`,
|
|
495
|
+
gate: name,
|
|
496
|
+
kind,
|
|
195
497
|
});
|
|
196
498
|
} catch {
|
|
197
499
|
// Recorded by absence: the next evidence read for this tree has no line.
|
|
@@ -241,15 +543,141 @@ export async function runGates(event, { root, manifest }) {
|
|
|
241
543
|
return { event, gates: results, failed: results.some((r) => r.status === 'failed') };
|
|
242
544
|
}
|
|
243
545
|
|
|
244
|
-
|
|
546
|
+
/**
|
|
547
|
+
* Materialize a detached worktree at HEAD so the pre-push gates run against the
|
|
548
|
+
* committed tree, not the working one. The worktree shares the repository's object
|
|
549
|
+
* store, so `git worktree add` is cheap and needs no clone. `node_modules` is
|
|
550
|
+
* symlinked in when the real root has one, because a script gate resolves its runner
|
|
551
|
+
* and binaries from there and a fresh worktree does not carry it.
|
|
552
|
+
*
|
|
553
|
+
* Fails closed: an unborn HEAD (no commits) or a git error returns `{ path: null }`
|
|
554
|
+
* with the reason, and the caller turns that into a gate failure rather than running
|
|
555
|
+
* against a tree it cannot name.
|
|
556
|
+
*
|
|
557
|
+
* @param {string} root the real repository root
|
|
558
|
+
* @returns {Promise<{path: string, error?: never}|{path: null, error: string}>}
|
|
559
|
+
*/
|
|
560
|
+
async function materializeHeadWorktree(root) {
|
|
561
|
+
const path = await mkdtemp(join(tmpdir(), 'head-gate-'));
|
|
562
|
+
try {
|
|
563
|
+
await exec('git', ['worktree', 'add', '--detach', path, 'HEAD'], { cwd: root });
|
|
564
|
+
} catch (error) {
|
|
565
|
+
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
566
|
+
return { path: null, error: `git worktree add failed: ${String(error.stderr ?? error.message).trim()}` };
|
|
567
|
+
}
|
|
568
|
+
const nodeModules = join(root, 'node_modules');
|
|
569
|
+
try {
|
|
570
|
+
await symlink(nodeModules, join(path, 'node_modules'));
|
|
571
|
+
} catch {
|
|
572
|
+
// Absent node_modules is the normal case for a repo with no install step; a
|
|
573
|
+
// dangling link is harmless, and `npm run` does not need it to exist.
|
|
574
|
+
}
|
|
575
|
+
return { path };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Remove a pre-push worktree. `--force` because a gate may have left it dirty (a
|
|
580
|
+
* formatter, a test that writes fixtures); the worktree is disposable, the real tree
|
|
581
|
+
* is not. Falls back to `rm -rf` plus a prune when git cannot remove it (a lock, a
|
|
582
|
+
* path in a state git does not expect), so a cleanup failure never leaks the tree.
|
|
583
|
+
*
|
|
584
|
+
* @param {string} root the real repository root
|
|
585
|
+
* @param {string} path the worktree to remove
|
|
586
|
+
*/
|
|
587
|
+
async function removeHeadWorktree(root, path) {
|
|
588
|
+
await exec('git', ['worktree', 'remove', '--force', path], { cwd: root }).catch(async () => {
|
|
589
|
+
await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
590
|
+
await exec('git', ['worktree', 'prune'], { cwd: root }).catch(() => {});
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The lines that report one event's gate run: the event, what was declared, every
|
|
596
|
+
* gate with its disposition, its reason and its remedy, and the verdict.
|
|
597
|
+
*
|
|
598
|
+
* Shared by `hooks run` and `slice finish` rather than written twice (ADR-002: the
|
|
599
|
+
* second real use is what the extraction exists for). Both run this dispatcher over
|
|
600
|
+
* the same event, and two renderings of one run are two ways for one of them to
|
|
601
|
+
* quietly stop printing a gate that failed.
|
|
602
|
+
*
|
|
603
|
+
* @param {string} event the hook event as git spells it
|
|
604
|
+
* @param {string[]} declared the gate names the manifest declares for it
|
|
605
|
+
* @param {object} result the value `runGates` returned
|
|
606
|
+
* @returns {string[]} the report lines, newline-free, in print order
|
|
607
|
+
*/
|
|
608
|
+
export function renderGateReport(event, declared, result) {
|
|
609
|
+
const lines = [`hooks ${event}`, `gates ${(declared ?? []).join(', ') || '(none declared)'}`];
|
|
610
|
+
for (const g of result.gates) {
|
|
611
|
+
lines.push(`${g.status.toUpperCase().padEnd(8)} ${g.name.padEnd(20)} ${g.detail}${g.adr ? ` [${g.adr}]` : ''}`);
|
|
612
|
+
if (g.remedy) lines.push(` remedy: ${g.remedy}`);
|
|
613
|
+
if (g.output) lines.push(...g.output.split('\n').map((l) => ` ${l}`));
|
|
614
|
+
}
|
|
615
|
+
lines.push(result.failed ? 'BLOCKED at least one gate failed' : 'PASSED');
|
|
616
|
+
return lines;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
async function run(argv, cwd, env) {
|
|
245
620
|
try {
|
|
246
|
-
const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
621
|
+
const { stdout, stderr } = await exec(argv[0], argv.slice(1), { cwd, env, maxBuffer: 8 * 1024 * 1024 });
|
|
247
622
|
return { code: 0, stdout, stderr };
|
|
248
623
|
} catch (error) {
|
|
249
624
|
return { code: typeof error.code === 'number' ? error.code : 1, stdout: error.stdout ?? '', stderr: error.stderr ?? String(error.message ?? error) };
|
|
250
625
|
}
|
|
251
626
|
}
|
|
252
627
|
|
|
628
|
+
/** The built-in gates this dispatcher can run, by name. */
|
|
629
|
+
const BUILTIN_GATES = new Map([['secret-scan', scanStaged]]);
|
|
630
|
+
|
|
631
|
+
/** How many findings the report prints; the detail carries the true count. */
|
|
632
|
+
const BUILTIN_REPORT_MAX = 12;
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Run a `builtin` gate in this process (ADR-100).
|
|
636
|
+
* Every outcome is a verdict with a reason: a built-in that does not exist, a
|
|
637
|
+
* diff git cannot read, or a secret found each fail with their own detail —
|
|
638
|
+
* none of them can degrade into a pass, which is the same fail-closed contract
|
|
639
|
+
* an unresolvable script gate has.
|
|
640
|
+
*/
|
|
641
|
+
async function runBuiltinGate(name, { root, staged, options }) {
|
|
642
|
+
const scan = BUILTIN_GATES.get(name);
|
|
643
|
+
if (scan === undefined) {
|
|
644
|
+
return {
|
|
645
|
+
ok: false,
|
|
646
|
+
detail: `no built-in implementation for gate "${name}"; this is a bug in ${CLI}`,
|
|
647
|
+
remedy: `remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
648
|
+
output: '',
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
if (staged.length === 0) {
|
|
652
|
+
return { ok: true, detail: 'nothing staged; no added line to scan', output: '' };
|
|
653
|
+
}
|
|
654
|
+
const result = await scan(root, options ?? {});
|
|
655
|
+
if (result.error !== null) {
|
|
656
|
+
return {
|
|
657
|
+
ok: false,
|
|
658
|
+
detail: `the staged diff could not be read: ${result.error}`,
|
|
659
|
+
remedy: 'the gate fails closed; inspect the repository state and commit again',
|
|
660
|
+
output: '',
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (result.findings.length === 0) {
|
|
664
|
+
return { ok: true, detail: `${result.lines} added line(s) scanned, no secret found`, output: '' };
|
|
665
|
+
}
|
|
666
|
+
const files = new Set(result.findings.map((f) => f.file));
|
|
667
|
+
const shown = result.findings.slice(0, BUILTIN_REPORT_MAX);
|
|
668
|
+
const more = result.findings.length - shown.length;
|
|
669
|
+
return {
|
|
670
|
+
ok: false,
|
|
671
|
+
detail: `${result.findings.length} probable secret(s) in ${files.size} staged file(s)`,
|
|
672
|
+
remedy:
|
|
673
|
+
'remove the value from the staged file (or unstage it with git restore --staged <path>) ' +
|
|
674
|
+
'and commit again; the report never prints the matched bytes',
|
|
675
|
+
output:
|
|
676
|
+
renderFindings(shown) +
|
|
677
|
+
(more > 0 ? `\n … ${more} more finding(s) not shown` : ''),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
253
681
|
/**
|
|
254
682
|
* Run a gate's resolution, as a shell line or as an argv.
|
|
255
683
|
*
|
|
@@ -266,9 +694,13 @@ async function run(argv, cwd) {
|
|
|
266
694
|
* rather than a flag on `run`.
|
|
267
695
|
*/
|
|
268
696
|
async function runGate(resolution, cwd) {
|
|
697
|
+
// The push override is for the push itself, not for the gates the hook runs.
|
|
698
|
+
// A test that asserts a push is denied sees the override and fails.
|
|
699
|
+
const env = { ...process.env };
|
|
700
|
+
delete env.SIMT_ALLOW;
|
|
269
701
|
if (resolution.shell === true && typeof resolution.command === 'string') {
|
|
270
702
|
try {
|
|
271
|
-
const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
703
|
+
const { stdout, stderr } = await exec('sh', ['-c', resolution.command], { cwd, env, maxBuffer: 8 * 1024 * 1024 });
|
|
272
704
|
return { code: 0, stdout, stderr };
|
|
273
705
|
} catch (error) {
|
|
274
706
|
return {
|
|
@@ -278,7 +710,7 @@ async function runGate(resolution, cwd) {
|
|
|
278
710
|
};
|
|
279
711
|
}
|
|
280
712
|
}
|
|
281
|
-
return run(resolution.argv, cwd);
|
|
713
|
+
return run(resolution.argv, cwd, env);
|
|
282
714
|
}
|
|
283
715
|
|
|
284
716
|
async function readPkg(root) {
|
package/src/hooks/gates.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
2
2
|
// Gate definitions for the shared hook dispatcher.
|
|
3
3
|
//
|
|
4
|
-
// A gate has one of
|
|
4
|
+
// A gate has one of four kinds, and the choice is not cosmetic:
|
|
5
5
|
//
|
|
6
6
|
// staged run a formatter over the STAGED FILES ONLY, then re-stage them.
|
|
7
7
|
// Whole-repo formatting from a pre-commit hook would rewrite
|
|
@@ -14,13 +14,18 @@ import { MANIFEST_FILE } from '../name.mjs';
|
|
|
14
14
|
// know what monkeyc or a Kotlin script path looks like in one repo
|
|
15
15
|
// and something else in another. Plan 0001 section 3.2 keeps repo
|
|
16
16
|
// commands and paths local; this is that ruling in code.
|
|
17
|
+
// builtin run inside this binary, with nothing to resolve on disk. For a
|
|
18
|
+
// check whose whole value is that a fresh clone has it: the staged-
|
|
19
|
+
// diff secret scan needs no script, no command and no tool in
|
|
20
|
+
// node_modules, and a gate that needed an install step would be a
|
|
21
|
+
// gate the repository that most needs it does not have (ADR-100).
|
|
17
22
|
//
|
|
18
23
|
// ADR-010 note: no gate invokes `npx` or `npm exec`. Script gates go through
|
|
19
24
|
// `npm run`, and staged gates resolve the formatter from the project's own
|
|
20
25
|
// `node_modules/.bin`, which is the declared dependency's binary rather than
|
|
21
26
|
// one fetched at hook time.
|
|
22
27
|
|
|
23
|
-
export const GATE_KINDS = new Set(['staged', 'script', 'command']);
|
|
28
|
+
export const GATE_KINDS = new Set(['staged', 'script', 'command', 'builtin']);
|
|
24
29
|
|
|
25
30
|
/** Extensions a `staged` formatter gate will hand to the tool. */
|
|
26
31
|
export const FORMATTABLE = new Set([
|
|
@@ -29,7 +34,7 @@ export const FORMATTABLE = new Set([
|
|
|
29
34
|
]);
|
|
30
35
|
|
|
31
36
|
/**
|
|
32
|
-
* @typedef {{name: string, kind: 'staged'|'script'|'command', adr: string,
|
|
37
|
+
* @typedef {{name: string, kind: 'staged'|'script'|'command'|'builtin', adr: string,
|
|
33
38
|
* script?: string, tools?: string[], why: string}} Gate
|
|
34
39
|
*/
|
|
35
40
|
|
|
@@ -88,6 +93,12 @@ export const GATES = {
|
|
|
88
93
|
adr: 'ADR-002',
|
|
89
94
|
why: 'a protocol change is a two-repo change; the drift check is what makes half of it fail loudly',
|
|
90
95
|
},
|
|
96
|
+
'secret-scan': {
|
|
97
|
+
name: 'secret-scan',
|
|
98
|
+
kind: 'builtin',
|
|
99
|
+
adr: 'ADR-100',
|
|
100
|
+
why: 'a staged diff is the last moment a secret can still be kept out of history, and the scan must be runnable by a clone with nothing installed',
|
|
101
|
+
},
|
|
91
102
|
};
|
|
92
103
|
|
|
93
104
|
export function getGate(name) {
|
package/src/hooks/resolve.mjs
CHANGED
|
@@ -68,7 +68,7 @@ export function hasScript(pkg, script) {
|
|
|
68
68
|
* @returns {Promise<{ok: true, argv: string[], kind: string, detail: string}
|
|
69
69
|
* | {ok: false, gate: string, kind: string, reason: string, remedy: string}>}
|
|
70
70
|
*/
|
|
71
|
-
export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
71
|
+
export async function resolveGate(gate, { root, pkg, staged, commands, runner = null }) {
|
|
72
72
|
const name = gate.name;
|
|
73
73
|
const declared = commands?.[name];
|
|
74
74
|
|
|
@@ -119,6 +119,20 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
|
119
119
|
};
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
if (gate.kind === 'builtin') {
|
|
123
|
+
// Nothing on disk to resolve: the implementation IS this binary, which is
|
|
124
|
+
// the whole point of the kind (ADR-100). A declared command was handled
|
|
125
|
+
// above and still wins, so a repository can swap the built-in for its own
|
|
126
|
+
// program exactly the way it can for any other gate.
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
kind: gate.kind,
|
|
130
|
+
argv: null,
|
|
131
|
+
builtin: gate.name,
|
|
132
|
+
detail: `built-in: ${gate.name}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
122
136
|
if (gate.kind === 'staged') {
|
|
123
137
|
const tool = await resolveStagedTool(root, gate);
|
|
124
138
|
if (tool.path === null) {
|
|
@@ -158,11 +172,17 @@ export async function resolveGate(gate, { root, pkg, staged, commands }) {
|
|
|
158
172
|
remedy: `add "${script}" to scripts, or remove "${name}" from ${MANIFEST_FILE} gates`,
|
|
159
173
|
};
|
|
160
174
|
}
|
|
175
|
+
// Q31: script gates run through the repo's own manager — `pnpm run`, not
|
|
176
|
+
// `npm run`, in a pnpm checkout. The default stays npm for callers that do
|
|
177
|
+
// not report one (older capabilities, hand-built contexts).
|
|
178
|
+
const { runScriptArgv, runScript } = await import('../ecosystem/node-runner.mjs');
|
|
179
|
+
const manager = runner ?? pkg?.packageManager?.split('@')?.[0] ?? 'npm';
|
|
180
|
+
const known = ['npm', 'pnpm', 'yarn', 'bun'].includes(manager) ? manager : 'npm';
|
|
161
181
|
return {
|
|
162
182
|
ok: true,
|
|
163
183
|
kind: gate.kind,
|
|
164
|
-
argv:
|
|
165
|
-
detail:
|
|
184
|
+
argv: runScriptArgv(known, script),
|
|
185
|
+
detail: runScript(known, script),
|
|
166
186
|
};
|
|
167
187
|
}
|
|
168
188
|
|