simt-dev 0.2.0 → 0.2.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 +173 -7
- package/README.md +4 -4
- package/docs/decisions/ADR-019-a-declared-command-is-the-runner.md +27 -0
- package/docs/decisions/ADR-059-the-version-a-repository-runs-is-the-one-it-pinned.md +75 -0
- package/docs/decisions/ADR-060-a-repository-with-nothing-to-run-is-adopted-and-says-so.md +131 -0
- package/docs/decisions/ADR-061-the-skill-registry-is-workspace-data.md +113 -0
- package/docs/decisions/ADR-062-a-fresh-adoption-seeds-the-gates-it-can-run.md +94 -0
- package/docs/decisions/ADR-063-the-adoption-escape-hatch-is-opt-in.md +69 -0
- package/docs/decisions/README.md +17 -0
- package/package.json +1 -2
- package/rules/stacks/dart.md +38 -0
- package/rules/stacks/deno.md +36 -0
- package/rules/stacks/dotnet.md +35 -0
- package/rules/stacks/elixir.md +39 -0
- package/rules/stacks/go.md +34 -0
- package/rules/stacks/ruby.md +36 -0
- package/rules/stacks/rust.md +34 -0
- package/rules/stacks/swift.md +37 -0
- package/src/argv.mjs +35 -6
- package/src/ci/dependabot.mjs +1 -1
- package/src/ci/workflow.mjs +33 -11
- package/src/commands/graph.mjs +13 -4
- package/src/commands/help.mjs +22 -4
- package/src/commands/hooks.mjs +173 -57
- package/src/commands/index.mjs +53 -45
- package/src/commands/init.mjs +89 -13
- package/src/commands/rules.mjs +54 -3
- package/src/commands/skills.mjs +143 -15
- package/src/commands/skillsinstall.mjs +3 -3
- package/src/commands/sync.mjs +153 -11
- package/src/compare/hooksstate.mjs +96 -9
- package/src/compare/row.mjs +34 -5
- package/src/decisions/index.mjs +21 -6
- package/src/graph/load.mjs +125 -14
- package/src/hooks/capability.mjs +48 -4
- package/src/hooks/dispatcher.mjs +50 -5
- package/src/hooks/gates.mjs +0 -2
- package/src/hooks/resolve.mjs +24 -25
- package/src/hooks/shim.mjs +29 -5
- package/src/identity.mjs +0 -11
- package/src/mcp/tools.mjs +2 -2
- package/src/output/index.mjs +8 -7
- package/src/package-version.mjs +74 -0
- package/src/projection/conflicts.mjs +56 -5
- package/src/projection/manifest.mjs +247 -25
- package/src/projection/mdc.mjs +4 -13
- package/src/projection/project.mjs +1 -1
- package/src/projection/rules.mjs +21 -8
- package/src/projection/targets.mjs +53 -7
- package/src/resolve/eval.mjs +64 -0
- package/src/resolve/fingerprint.mjs +1 -0
- package/src/resolve/near.mjs +26 -7
- package/src/resolve/resolve.mjs +1 -1
- package/src/resolve/source.mjs +1 -1
- package/src/workspace.mjs +0 -9
- package/graph/skills.graph.json +0 -1622
package/src/ci/workflow.mjs
CHANGED
|
@@ -50,6 +50,7 @@ import { PACKAGE_ROOT } from '../workspace.mjs';
|
|
|
50
50
|
import { PACKAGE_NAME, WORKFLOW_FILE_NAME } from '../name.mjs';
|
|
51
51
|
import { renderLocator } from '../locate.mjs';
|
|
52
52
|
import { CLI } from '../name.mjs';
|
|
53
|
+
import { packageVersion } from '../package-version.mjs';
|
|
53
54
|
|
|
54
55
|
/** Directory whose presence means this repo runs GitHub Actions. */
|
|
55
56
|
export const WORKFLOWS_DIR = '.github/workflows';
|
|
@@ -169,12 +170,16 @@ function skeleton(name, steps, { matrix = null } = {}) {
|
|
|
169
170
|
const job = matrix?.node ? '\${{ matrix.os }} node \${{ matrix.node }}' : name;
|
|
170
171
|
return `name: ${name}
|
|
171
172
|
|
|
173
|
+
# Pull requests only, and that is a decision rather than a default.
|
|
174
|
+
#
|
|
175
|
+
# A gate that runs on every push re-runs itself for every commit that pushes a
|
|
176
|
+
# branch forward, including the ones nobody asked it about, and its verdict has to
|
|
177
|
+
# be cleared before the next one lands. \`pull_request\` asks one question — may this
|
|
178
|
+
# merge — and answers it once. \`main\` here is the branch protection list, not a
|
|
179
|
+
# trigger: the gate that has to pass is the one the pull request runs, and the one
|
|
180
|
+
# on \`main\` is whatever CI the repository already has there.
|
|
172
181
|
on:
|
|
173
182
|
pull_request:
|
|
174
|
-
push:
|
|
175
|
-
branches:
|
|
176
|
-
- main
|
|
177
|
-
- master
|
|
178
183
|
|
|
179
184
|
# Read-only is enough to check a repo, and a gate job does not need to write to it.
|
|
180
185
|
permissions:
|
|
@@ -235,7 +240,7 @@ function indent(text, spaces) {
|
|
|
235
240
|
* @param {{own?: boolean}} [options]
|
|
236
241
|
* @returns {string} YAML, LF-terminated
|
|
237
242
|
*/
|
|
238
|
-
export function renderWorkflow({ own = false } = {}) {
|
|
243
|
+
export function renderWorkflow({ own = false, npm = true, version } = {}) {
|
|
239
244
|
if (own) {
|
|
240
245
|
const floor = nodeFloor();
|
|
241
246
|
return `${header(true)}${skeleton(
|
|
@@ -267,17 +272,34 @@ export function renderWorkflow({ own = false } = {}) {
|
|
|
267
272
|
)}\n`;
|
|
268
273
|
}
|
|
269
274
|
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
275
|
+
const v = version ?? packageVersion();
|
|
276
|
+
// The install step is the whole of S35's consumer CI: a repository with npm pins
|
|
277
|
+
// simt-dev as a devDependency and `npm ci` restores it; one without (Gradle,
|
|
278
|
+
// Connect IQ) has no node_modules to pin in, so CI installs the same pinned global
|
|
279
|
+
// binary the machine remedy names. The two must agree, or the gate passes in one
|
|
280
|
+
// place and cannot start in the other.
|
|
281
|
+
const install = npm
|
|
282
|
+
? [
|
|
283
|
+
' - name: install dependencies',
|
|
284
|
+
' # `npm ci` needs a lockfile; a repo without one is still gated, not skipped.',
|
|
285
|
+
' run: npm ci || npm install',
|
|
286
|
+
]
|
|
287
|
+
: [
|
|
288
|
+
' - name: install the pinned gate binary',
|
|
289
|
+
' # No node_modules to pin in, so a pinned global install: the same bytes',
|
|
290
|
+
' # the machine remedy names, so the gate cannot pass locally and fail here.',
|
|
291
|
+
` run: npm i -g ${PACKAGE_NAME}@${v}`,
|
|
292
|
+
];
|
|
293
|
+
const remedy = npm
|
|
294
|
+
? `REMEDY run: npm i -D ${PACKAGE_NAME} (the gate runs from the pinned dependency)`
|
|
295
|
+
: `REMEDY run: npm i -g ${PACKAGE_NAME}@${v} (the gate runs from a pinned global install)`;
|
|
296
|
+
const locator = renderLocator('check --root "$ROOT"', { remedy });
|
|
273
297
|
return `${header(false)}${skeleton(
|
|
274
298
|
`${CLI} check`,
|
|
275
299
|
[
|
|
276
300
|
uses('checkout'),
|
|
277
301
|
setupNode(nodeFloor()),
|
|
278
|
-
|
|
279
|
-
' # \`npm ci\` needs a lockfile; a repo without one is still gated, not skipped.',
|
|
280
|
-
' run: npm ci || npm install',
|
|
302
|
+
...install,
|
|
281
303
|
` - name: ${CLI} check`,
|
|
282
304
|
' run: |',
|
|
283
305
|
indent(`set -eu\nROOT="\${GITHUB_WORKSPACE}"\n${locator}`, 10),
|
package/src/commands/graph.mjs
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
// the offending node, the file it disagrees with, and both numbers.
|
|
8
8
|
|
|
9
9
|
import { EXIT, emit, fail, isJson } from '../output/index.mjs';
|
|
10
|
-
import { loadRegistry
|
|
10
|
+
import { loadRegistry } from '../graph/load.mjs';
|
|
11
|
+
import { CLI } from '../name.mjs';
|
|
11
12
|
|
|
12
13
|
export async function lintCommand(positional, flags) {
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
// The resolved path comes back from the loader, because when no positional is given it
|
|
15
|
+
// falls back to the member that holds the registry in this workspace (ADR-061) - and the
|
|
16
|
+
// reader should see where it actually looked, not a constant that no longer exists.
|
|
17
|
+
const { ok, registry, errors, warnings, verified, workspace, path } = await loadRegistry(
|
|
18
|
+
positional[0] ?? null,
|
|
19
|
+
flags,
|
|
20
|
+
);
|
|
15
21
|
|
|
16
22
|
if (isJson(flags)) {
|
|
17
23
|
emit(
|
|
@@ -40,7 +46,10 @@ export async function lintCommand(positional, flags) {
|
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
if (!registry) {
|
|
43
|
-
|
|
49
|
+
// The default remedy for this exit code is `run: simt-dev graph lint`, which from
|
|
50
|
+
// inside `graph lint` is circular: the reader just ran it and got this. Name the
|
|
51
|
+
// argument the command takes, because an explicit path is the way out (ADR-061).
|
|
52
|
+
return fail(errors[0] ?? `${path}: unreadable`, EXIT.REGISTRY, `run: ${CLI} graph lint <path-to-registry>`);
|
|
44
53
|
}
|
|
45
54
|
|
|
46
55
|
const lines = [
|
package/src/commands/help.mjs
CHANGED
|
@@ -4,7 +4,8 @@ import { CLI } from '../name.mjs';
|
|
|
4
4
|
|
|
5
5
|
const COMMANDS = `${CLI} init [path] adopt or scaffold a repo into the ecosystem
|
|
6
6
|
(dry-run unless --yes; conflicts block without
|
|
7
|
-
--accept-conflicts;
|
|
7
|
+
--accept-conflicts; in place unless --backup;
|
|
8
|
+
never commits or pushes)
|
|
8
9
|
${CLI} doctor classify a repo and check config health
|
|
9
10
|
${CLI} check the gate: projection drift, manifest validity, policy
|
|
10
11
|
${CLI} sync [--yes] regenerate the projection (idempotent)
|
|
@@ -19,6 +20,10 @@ ${CLI} skills brief --task tier-1 pre-assembled brief <- the common case
|
|
|
19
20
|
${CLI} skills show <name> tier-2 body (respects --max-bytes)
|
|
20
21
|
${CLI} skills why <name> the signal chain that selected it
|
|
21
22
|
${CLI} skills find <query> search across capability and keyword
|
|
23
|
+
${CLI} skills eval [corpus] measure the resolver against a gold corpus
|
|
24
|
+
(reports precision and recall; exits non-zero on
|
|
25
|
+
regression; without an argument, the corpus in this
|
|
26
|
+
checkout)
|
|
22
27
|
${CLI} skills install <name> pin a skill into the repo as a local override
|
|
23
28
|
(dry-run unless --yes; a multi-variant skill needs
|
|
24
29
|
--variant or --from rather than a guess)
|
|
@@ -26,9 +31,15 @@ ${CLI} skills install <name> pin a skill into the repo as a local override
|
|
|
26
31
|
${CLI} rules list the rule catalogue (--root marks what applies to a repo)
|
|
27
32
|
${CLI} rules show <id> one rule body
|
|
28
33
|
${CLI} rules why <id|ADR-NNN> the decision that settled it, and whether it is still in force
|
|
34
|
+
(--all prints every ruling, not the first two)
|
|
29
35
|
${CLI} hooks install [--yes] install the shared dispatcher; report what it would run
|
|
30
36
|
(dry-run unless --yes; refuses to take over routing git
|
|
31
37
|
already uses, and --yes is that consent)
|
|
38
|
+
${CLI} hooks status what each git event routes to, and whether it is wired
|
|
39
|
+
${CLI} hooks run <event> run one event's gates now, as git would
|
|
40
|
+
(pre-commit, pre-push, post-merge, ...)
|
|
41
|
+
${CLI} graph lint check the skill registry against the skills on disk
|
|
42
|
+
(byte counts, dangling edges, unresolvable variants)
|
|
32
43
|
${CLI} ci simt-dev's own .github files: create or refresh them
|
|
33
44
|
(dry-run unless --yes; refreshes the templates' own copies
|
|
34
45
|
and no repository else's)
|
|
@@ -55,9 +66,16 @@ const FLAGS = `--json structured output
|
|
|
55
66
|
file, then the sibling heuristic if it verifies)
|
|
56
67
|
--dry-run report what would change, change nothing
|
|
57
68
|
--yes assume yes; required in automation, never prompts
|
|
58
|
-
--
|
|
69
|
+
--check report drift and write nothing (sync, ci)
|
|
70
|
+
--all ignore a cap: every ruling of a record, or the skills budget
|
|
59
71
|
--variant <id> which body of a multi-variant skill to use
|
|
60
|
-
--from <repo/path> install from a declared source by name
|
|
72
|
+
--from <repo/path> install from a declared source by name
|
|
73
|
+
--list print the MCP tool surface without reading stdin (mcp)
|
|
74
|
+
--accept-conflicts init: adopt rules that collide with hand-written ones
|
|
75
|
+
--backup init: tag the pre-adoption commit and branch onto
|
|
76
|
+
simt-dev/adopt before writing (the undo path)
|
|
77
|
+
--help this text
|
|
78
|
+
--version the version, and the SHA it was built from`;
|
|
61
79
|
|
|
62
80
|
const PRINCIPLES = `design rules:
|
|
63
81
|
deterministic same repo + same task = same answer, forever
|
|
@@ -78,7 +96,7 @@ export async function help(argv) {
|
|
|
78
96
|
[
|
|
79
97
|
'simt-dev - shared agent rules, skills and gates for a repo ecosystem',
|
|
80
98
|
'',
|
|
81
|
-
|
|
99
|
+
`usage: ${CLI} <command> [args] [flags]`,
|
|
82
100
|
'',
|
|
83
101
|
'commands:',
|
|
84
102
|
COMMANDS,
|
package/src/commands/hooks.mjs
CHANGED
|
@@ -13,19 +13,20 @@
|
|
|
13
13
|
// foreign hook is found by asking git rather than by reading config.
|
|
14
14
|
|
|
15
15
|
import { execFile } from 'node:child_process';
|
|
16
|
-
import { chmod, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { chmod, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises';
|
|
17
17
|
import { isAbsolute, join, relative } from 'node:path';
|
|
18
18
|
import { promisify } from 'node:util';
|
|
19
19
|
|
|
20
20
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
21
21
|
import { MANIFEST, readManifest } from '../projection/manifest.mjs';
|
|
22
22
|
import { EVENTS, HOOKS_DIR, HOOKS_PATH_KEY, fileFor, renderChainedShim, renderShim } from '../hooks/shim.mjs';
|
|
23
|
-
import { readHooksPath, readHooksState } from '../compare/hooksstate.mjs';
|
|
23
|
+
import { GATE_KEY, gateCount, readHooksPath, readHooksState, wantsShim } from '../compare/hooksstate.mjs';
|
|
24
24
|
import { GATES } from '../hooks/gates.mjs';
|
|
25
25
|
import { knownGates } from '../hooks/resolve.mjs';
|
|
26
26
|
import { runGates } from '../hooks/dispatcher.mjs';
|
|
27
27
|
import { CLI } from '../name.mjs';
|
|
28
28
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
29
|
+
import { packageVersion, hasNpm } from '../package-version.mjs';
|
|
29
30
|
|
|
30
31
|
const exec = promisify(execFile);
|
|
31
32
|
|
|
@@ -70,12 +71,6 @@ async function runHook(args, flags) {
|
|
|
70
71
|
return result.failed ? EXIT.FAIL : EXIT.OK;
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
const GATE_KEY = { 'pre-commit': 'preCommit', 'pre-push': 'prePush' };
|
|
74
|
-
|
|
75
|
-
function gateCount(manifest, event) {
|
|
76
|
-
return (manifest?.gates?.[GATE_KEY[event]] ?? []).length;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
74
|
/**
|
|
80
75
|
* The path to write into a chained shim: relative to the repository's top level
|
|
81
76
|
* wherever the hook lives inside it.
|
|
@@ -106,19 +101,6 @@ async function relativeTarget(root, target) {
|
|
|
106
101
|
return rel === '' || rel.startsWith('..') ? resolved : rel;
|
|
107
102
|
}
|
|
108
103
|
|
|
109
|
-
/**
|
|
110
|
-
* Whether a missing shim for this event is a defect.
|
|
111
|
-
*
|
|
112
|
-
* A repo that declares no gate for an event has no reason to want a shim for it,
|
|
113
|
-
* so its absence is the correct end state. A repo with NO manifest is different:
|
|
114
|
-
* we do not know what it wants, so we cannot rule the shim out - absence of
|
|
115
|
-
* knowledge is not a decision.
|
|
116
|
-
*/
|
|
117
|
-
function wantsShim(manifest, exists, event) {
|
|
118
|
-
if (!exists) return true;
|
|
119
|
-
return gateCount(manifest, event) > 0;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
104
|
async function installHooks(root, flags) {
|
|
123
105
|
const { manifest, exists, error } = await readManifest(root);
|
|
124
106
|
if (error) return fail(`${MANIFEST_FILE}: ${error}`, EXIT.CONFIG);
|
|
@@ -126,7 +108,8 @@ async function installHooks(root, flags) {
|
|
|
126
108
|
return fail(`${root} has no ${MANIFEST_FILE}; gates must be declared before hooks can run them`, EXIT.CONFIG, `run: ${CLI} init --root ${root}`);
|
|
127
109
|
}
|
|
128
110
|
|
|
129
|
-
const
|
|
111
|
+
const declaresGate = (event) => wantsShim(manifest, exists, event);
|
|
112
|
+
const state = await readHooksState(root, { declaresGate });
|
|
130
113
|
const current = state.current;
|
|
131
114
|
const wants = HOOKS_DIR;
|
|
132
115
|
|
|
@@ -139,30 +122,19 @@ async function installHooks(root, flags) {
|
|
|
139
122
|
// for a plan and then `--yes` - advice that printed a plan and wrote anyway.
|
|
140
123
|
const mode = writeMode(flags);
|
|
141
124
|
|
|
125
|
+
// The release this install renders, and the kind of repository it installs into: the
|
|
126
|
+
// remedy a shim prints when it cannot find the binary must name the install that
|
|
127
|
+
// actually works here — a devDependency where package.json exists, a pinned global
|
|
128
|
+
// install where there is no node_modules to pin in.
|
|
129
|
+
const version = packageVersion();
|
|
130
|
+
const npm = await hasNpm(root);
|
|
131
|
+
|
|
142
132
|
// Only install a shim for an event that declares at least one gate. A shim with
|
|
143
133
|
// an empty gate list enforces nothing, yet merely existing it REPLACES whatever
|
|
144
134
|
// git was running - so installing one over a working hand-written hook is a
|
|
145
135
|
// downgrade dressed as an upgrade. `init` already says this in prose; install
|
|
146
136
|
// has to agree, or the two commands describe different tools.
|
|
147
137
|
const wanted = EVENTS.filter((e) => gateCount(manifest, e) > 0);
|
|
148
|
-
if (wanted.length === 0) {
|
|
149
|
-
if (isJson(flags)) {
|
|
150
|
-
emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
|
|
151
|
-
return EXIT.FAIL;
|
|
152
|
-
}
|
|
153
|
-
emit(
|
|
154
|
-
[
|
|
155
|
-
`repo ${root}`,
|
|
156
|
-
'',
|
|
157
|
-
`BLOCKED ${root} declares no gate it can run, so there is nothing to enforce.`,
|
|
158
|
-
' Installing shims anyway would stop running whatever git runs now',
|
|
159
|
-
' and replace it with a gate that checks nothing.',
|
|
160
|
-
`REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
|
|
161
|
-
].join('\n'),
|
|
162
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
163
|
-
);
|
|
164
|
-
return EXIT.FAIL;
|
|
165
|
-
}
|
|
166
138
|
|
|
167
139
|
// Foreign hooks on events this tool does NOT gate. Routing replaces the whole
|
|
168
140
|
// directory git reads from, so these have to be carried across explicitly or they
|
|
@@ -179,7 +151,21 @@ async function installHooks(root, flags) {
|
|
|
179
151
|
// Keying this on the event vocabulary instead of on the manifest cost a repository
|
|
180
152
|
// git-lfs hook: pre-push is a gated event in general, but this repo declares no
|
|
181
153
|
// pre-push gate, so nothing replaces its hook and routing would have dropped it.
|
|
182
|
-
|
|
154
|
+
// A chained shim and the hook it chains must be two different files.
|
|
155
|
+
//
|
|
156
|
+
// `foreignHooks` lists every executable file in the directory git reads hooks from -
|
|
157
|
+
// and when that directory is this tool's own, a file a person dropped in there is
|
|
158
|
+
// listed too. Chaining it means writing a shim at `.simt-hooks/<name>` whose TARGET is
|
|
159
|
+
// `.simt-hooks/<name>`: the same path. The shim overwrites the hook it was written to
|
|
160
|
+
// preserve and then runs itself, so the hook stops working and git recurses in a loop
|
|
161
|
+
// nothing reports. Confirmed at the previous release, which printed
|
|
162
|
+
// `UPDATED .simt-hooks/mine (chained: runs .simt-hooks/mine)`.
|
|
163
|
+
//
|
|
164
|
+
// So such a hook is not chained. It is not stopped either - nothing overwrites it, so
|
|
165
|
+
// git keeps running it - and it is reported as what it is below.
|
|
166
|
+
const chainable = (event, target) => target !== fileFor(event);
|
|
167
|
+
|
|
168
|
+
const candidates = [
|
|
183
169
|
// Already chained by an earlier install, read back off disk: re-rendered from the
|
|
184
170
|
// file's own contents so the bytes cannot drift on a second run.
|
|
185
171
|
...(await Promise.all(
|
|
@@ -198,12 +184,71 @@ async function installHooks(root, flags) {
|
|
|
198
184
|
.filter((f) => gateCount(manifest, f.event) === 0)
|
|
199
185
|
.map(async (f) => ({ event: f.event, target: await relativeTarget(root, f.path) })),
|
|
200
186
|
)),
|
|
201
|
-
]
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const chained = candidates
|
|
190
|
+
.filter((c) => chainable(c.event, c.target))
|
|
191
|
+
.reduce((all, c) => (all.some((x) => x.event === c.event) ? all : [...all, c]), []);
|
|
192
|
+
|
|
193
|
+
// Hooks that keep running untouched because chaining them was impossible. Separate
|
|
194
|
+
// from `chained` because the notes below say opposite things about the two: one runs
|
|
195
|
+
// the hook for you, the other leaves it alone and it still runs. Reporting both as
|
|
196
|
+
// "stopped running" - which is what this did - was false for this one and would push an
|
|
197
|
+
// operator to restore a hook that is already running.
|
|
198
|
+
const leftRunning = candidates.filter((c) => !chainable(c.event, c.target));
|
|
199
|
+
|
|
200
|
+
// Shims this tool wrote for an event the manifest no longer gates.
|
|
201
|
+
//
|
|
202
|
+
// `install` only ever ADDED files, so dropping a gate from the manifest left its shim
|
|
203
|
+
// on disk: still routed through `core.hooksPath`, still `PRESENT` in `hooks status`,
|
|
204
|
+
// and enforcing nothing. That is the exact harm the refusal below is written to
|
|
205
|
+
// prevent - reached by the sanctioned path rather than by the blocked one.
|
|
206
|
+
//
|
|
207
|
+
// It was a dead end rather than only a defect. With the LAST gate removed there is
|
|
208
|
+
// nothing to install, so `install` refused and wrote nothing, leaving the shim, the
|
|
209
|
+
// routing, and a remedy - "add the gates this repo can actually run" - telling a
|
|
210
|
+
// reader to undo the change they had just made, for a gate that may be gone because
|
|
211
|
+
// the script it ran was deleted. The only way out was to re-declare the gate and
|
|
212
|
+
// delete the file by hand.
|
|
213
|
+
//
|
|
214
|
+
// Read from `state.installed`, which describes every shim this install is responsible
|
|
215
|
+
// for, and not from `state.staleOurs`: that loop skips a shim the moment it matches
|
|
216
|
+
// what this run would render, so a *current* shim for an ungated event never reaches
|
|
217
|
+
// it - which is the common case here and the one that matters. `ours` on each entry is
|
|
218
|
+
// what keeps a file a person wrote in this directory from being deleted on their
|
|
219
|
+
// behalf; `wanted` and `chained` are what this run writes, so what is left is exactly
|
|
220
|
+
// the set nothing else here touches.
|
|
221
|
+
const obsolete = state.installed.filter(
|
|
222
|
+
(i) => i.present && i.ours && !wanted.includes(i.event) && !chained.some((c) => c.event === i.event),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Nothing to write and nothing to take away is the only refusal. It sits after
|
|
226
|
+
// `obsolete` because a repository whose last gate just left the manifest has
|
|
227
|
+
// something to take away, and blocking that one left no way out except undoing
|
|
228
|
+
// the change by hand - see the note on `obsolete`.
|
|
229
|
+
if (wanted.length === 0 && obsolete.length === 0) {
|
|
230
|
+
if (isJson(flags)) {
|
|
231
|
+
emitJson({ ok: false, command: 'hooks install', root, nothingToEnforce: true, filesWritten: false });
|
|
232
|
+
return EXIT.FAIL;
|
|
233
|
+
}
|
|
234
|
+
emit(
|
|
235
|
+
[
|
|
236
|
+
`repo ${root}`,
|
|
237
|
+
'',
|
|
238
|
+
`BLOCKED ${root} declares no gate it can run, so there is nothing to enforce.`,
|
|
239
|
+
' Installing shims anyway would stop running whatever git runs now',
|
|
240
|
+
' and replace it with a gate that checks nothing.',
|
|
241
|
+
`REMEDY add the gates this repo can actually run to ${MANIFEST_FILE}, then re-run.`,
|
|
242
|
+
].join('\n'),
|
|
243
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
244
|
+
);
|
|
245
|
+
return EXIT.FAIL;
|
|
246
|
+
}
|
|
202
247
|
|
|
203
248
|
const plan = [];
|
|
204
249
|
for (const event of wanted) {
|
|
205
250
|
const path = join(root, fileFor(event));
|
|
206
|
-
const source = renderShim(event);
|
|
251
|
+
const source = renderShim(event, { npm, version });
|
|
207
252
|
const existing = await readIfExists(path);
|
|
208
253
|
if (existing === source) {
|
|
209
254
|
plan.push({ file: fileFor(event), action: 'unchanged' });
|
|
@@ -224,7 +269,26 @@ async function installHooks(root, flags) {
|
|
|
224
269
|
note: `chained: runs ${c.target}`,
|
|
225
270
|
});
|
|
226
271
|
}
|
|
227
|
-
const
|
|
272
|
+
for (const h of obsolete) {
|
|
273
|
+
plan.push({
|
|
274
|
+
file: fileFor(h.event),
|
|
275
|
+
action: 'removed',
|
|
276
|
+
// Named in the plan because the reader approving this write is about to lose a
|
|
277
|
+
// file, and "REMOVED pre-push" does not say why - which is the difference between
|
|
278
|
+
// agreeing with it and discovering later that a gate had been dropped by accident.
|
|
279
|
+
note: `no gate for ${h.event} in ${MANIFEST_FILE}; the shim enforced nothing`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
const routed = wanted.length > 0 || chained.length > 0;
|
|
283
|
+
|
|
284
|
+
// The routing this install leaves behind, which is not always `wants`. A repository
|
|
285
|
+
// that removed its last gate has no shims to route to, and leaving `core.hooksPath`
|
|
286
|
+
// pointed at the directory would stop its own hooks running - the plan has to say so,
|
|
287
|
+
// because a plan that prints "core.hooksPath updated .simt-hooks -> .simt-hooks" while
|
|
288
|
+
// the command unsets it is worse than no plan.
|
|
289
|
+
const leaves = routed ? wants : null;
|
|
290
|
+
const pathAction =
|
|
291
|
+
leaves === current ? 'unchanged' : leaves === null ? 'removed' : current === null ? 'created' : 'updated';
|
|
228
292
|
|
|
229
293
|
// Refuse to take over routing git is already using without explicit consent.
|
|
230
294
|
// Two cases, and the second one is the one that bites: core.hooksPath already
|
|
@@ -238,7 +302,7 @@ async function installHooks(root, flags) {
|
|
|
238
302
|
? state.foreign.map((f) => ` git runs ${f.event} at ${f.path} (${f.bytes} bytes)`)
|
|
239
303
|
: [` existing ${HOOKS_PATH_KEY} = ${current}`];
|
|
240
304
|
if (isJson(flags)) {
|
|
241
|
-
emitJson({ ok: false, command: 'hooks install', root, currentHooksPath: current, wouldSet:
|
|
305
|
+
emitJson({ ok: false, command: 'hooks install', root, currentHooksPath: current, wouldSet: leaves, takeover: true, foreign: state.foreign, plan, filesWritten: false });
|
|
242
306
|
return EXIT.FAIL;
|
|
243
307
|
}
|
|
244
308
|
emit(
|
|
@@ -258,14 +322,20 @@ async function installHooks(root, flags) {
|
|
|
258
322
|
|
|
259
323
|
if (!mode.apply) {
|
|
260
324
|
if (isJson(flags)) {
|
|
261
|
-
|
|
325
|
+
// `leaves`, not `wants`. A plan that would remove the last shim also un-routes
|
|
326
|
+
// git, and a payload promising to set a directory the command is about to unset
|
|
327
|
+
// is the same lie in a shape a machine reads.
|
|
328
|
+
emitJson({ ok: true, command: 'hooks install', root, dryRun: true, currentHooksPath: current, wouldSet: leaves, plan, filesWritten: false });
|
|
262
329
|
return EXIT.OK;
|
|
263
330
|
}
|
|
264
331
|
emit(
|
|
265
332
|
renderPlan(
|
|
266
333
|
root,
|
|
267
334
|
current,
|
|
268
|
-
|
|
335
|
+
// `leaves`, for the same reason the `--json` payload uses it: this plan says
|
|
336
|
+
// REMOVED beside the last shim, and a reader of it is entitled to read that
|
|
337
|
+
// the routing is going too, rather than being told it is unchanged.
|
|
338
|
+
leaves,
|
|
269
339
|
pathAction,
|
|
270
340
|
plan,
|
|
271
341
|
mode.dryRun
|
|
@@ -281,7 +351,7 @@ async function installHooks(root, flags) {
|
|
|
281
351
|
await mkdir(join(root, HOOKS_DIR), { recursive: true });
|
|
282
352
|
for (const event of wanted) {
|
|
283
353
|
const path = join(root, fileFor(event));
|
|
284
|
-
await writeFile(path, renderShim(event));
|
|
354
|
+
await writeFile(path, renderShim(event, { npm, version }));
|
|
285
355
|
await chmod(path, 0o755);
|
|
286
356
|
}
|
|
287
357
|
for (const c of chained) {
|
|
@@ -289,7 +359,19 @@ async function installHooks(root, flags) {
|
|
|
289
359
|
await writeFile(path, renderChainedShim(c.event, c.target));
|
|
290
360
|
await chmod(path, 0o755);
|
|
291
361
|
}
|
|
292
|
-
|
|
362
|
+
for (const h of obsolete) {
|
|
363
|
+
await rm(join(root, fileFor(h.event)));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// `core.hooksPath` tells git to read ONE directory for EVERY event, so leaving it
|
|
367
|
+
// pointing at a directory of no shims stops the repository's own hooks - the ones in
|
|
368
|
+
// `.git/hooks` that predate this tool - from running, silently. Unsetting it is the
|
|
369
|
+
// only way back, and this is the only place that knows the last shim just left.
|
|
370
|
+
if (routed) {
|
|
371
|
+
await exec('git', ['config', HOOKS_PATH_KEY, wants], { cwd: root });
|
|
372
|
+
} else if (current === wants) {
|
|
373
|
+
await exec('git', ['config', '--unset', HOOKS_PATH_KEY], { cwd: root });
|
|
374
|
+
}
|
|
293
375
|
|
|
294
376
|
const notes = [];
|
|
295
377
|
const chainedEvents = new Set(chained.map((c) => c.event));
|
|
@@ -299,28 +381,62 @@ async function installHooks(root, flags) {
|
|
|
299
381
|
// chained and genuinely stopped running. Saying "stopped running" about both would
|
|
300
382
|
// be wrong about the first, and it would push an operator to restore a hook that is
|
|
301
383
|
// already being run for them.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
)
|
|
384
|
+
if (chainedEvents.has(f.event)) {
|
|
385
|
+
notes.push(
|
|
386
|
+
`chained ${f.event}: ${fileFor(f.event)} runs ${f.path} (${f.bytes} bytes), so it keeps running`,
|
|
387
|
+
);
|
|
388
|
+
} else if (leftRunning.some((c) => c.event === f.event)) {
|
|
389
|
+
// A third case, and the one that was misreported: a hook this tool could not chain
|
|
390
|
+
// because the shim would have had to run itself. Nothing overwrote it, so it still
|
|
391
|
+
// runs - which is the opposite of what the branch below says.
|
|
392
|
+
notes.push(
|
|
393
|
+
`${f.event} at ${f.path} (${f.bytes} bytes) is in this tool's own hooks directory, so it cannot be chained to itself and was left untouched; it keeps running`,
|
|
394
|
+
);
|
|
395
|
+
} else {
|
|
396
|
+
notes.push(
|
|
397
|
+
`stopped running ${f.event} at ${f.path} (${f.bytes} bytes); the file is still on disk, untouched - declare it in ${MANIFEST_FILE} gates.commands to have the dispatcher run it`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
307
400
|
}
|
|
308
401
|
if (state.foreign.length === 0 && takeover) {
|
|
309
402
|
notes.push(`replaced an existing core.${HOOKS_PATH_KEY} (${current}); the old hooks are still on disk, untouched`);
|
|
310
403
|
}
|
|
404
|
+
for (const h of obsolete) {
|
|
405
|
+
notes.push(
|
|
406
|
+
`removed ${fileFor(h.event)}: ${MANIFEST_FILE} declares no ${h.event} gate, so the shim enforced nothing and still replaced git's own hook`,
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (leaves === null && current !== null) {
|
|
410
|
+
notes.push(
|
|
411
|
+
`unset ${HOOKS_PATH_KEY}; git reads its hooks from .git/hooks again, so the repository's own hooks run`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
311
414
|
|
|
312
415
|
if (isJson(flags)) {
|
|
313
|
-
emitJson({ ok: true, command: 'hooks install', root, hooksPath:
|
|
416
|
+
emitJson({ ok: true, command: 'hooks install', root, hooksPath: leaves, previousHooksPath: current, plan, filesWritten: true, notes });
|
|
314
417
|
return EXIT.OK;
|
|
315
418
|
}
|
|
316
|
-
emit(
|
|
419
|
+
emit(
|
|
420
|
+
renderPlan(
|
|
421
|
+
root,
|
|
422
|
+
current,
|
|
423
|
+
leaves,
|
|
424
|
+
pathAction,
|
|
425
|
+
plan,
|
|
426
|
+
routed ? 'WROTE shims and set core.hooksPath' : 'WROTE no shims remain; core.hooksPath unset',
|
|
427
|
+
manifest,
|
|
428
|
+
),
|
|
429
|
+
{ maxBytes: flags.maxBytes ?? 0 },
|
|
430
|
+
);
|
|
317
431
|
for (const n of notes) note(`note ${n}`);
|
|
318
432
|
note(`note never committed or pushed (ADR-001); review, then commit yourself`);
|
|
319
433
|
return EXIT.OK;
|
|
320
434
|
}
|
|
321
435
|
|
|
436
|
+
// `wants` is null when this install removes the last shim, and "null" is not what a
|
|
437
|
+
// reader should read in a plan that says REMOVED next to it.
|
|
322
438
|
function renderPlan(root, current, wants, pathAction, plan, headline, manifest) {
|
|
323
|
-
const lines = [`repo ${root}`, `${HOOKS_PATH_KEY.padEnd(15)} ${pathAction} ${current ?? '(unset)'} -> ${wants}`, ''];
|
|
439
|
+
const lines = [`repo ${root}`, `${HOOKS_PATH_KEY.padEnd(15)} ${pathAction} ${current ?? '(unset)'} -> ${wants ?? '(unset)'}`, ''];
|
|
324
440
|
for (const p of plan) lines.push(`${p.action.toUpperCase().padEnd(9)} ${p.file}${p.note ? ` (${p.note})` : ''}`);
|
|
325
441
|
lines.push('', headline);
|
|
326
442
|
lines.push(`gates preCommit = ${describeGates(manifest, 'preCommit')}`);
|
package/src/commands/index.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
briefCommand as skillsBrief,
|
|
16
16
|
showCommand as skillsShow,
|
|
17
17
|
whyCommand as skillsWhy,
|
|
18
|
+
evalCommand as skillsEval,
|
|
18
19
|
} from './skills.mjs';
|
|
19
20
|
import { syncCommand, checkCommand } from './sync.mjs';
|
|
20
21
|
import { initCommand } from './init.mjs';
|
|
@@ -44,6 +45,50 @@ const GROUPS = new Map([
|
|
|
44
45
|
['hooks', 'status'],
|
|
45
46
|
]);
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Every command this binary dispatches, in one place.
|
|
50
|
+
*
|
|
51
|
+
* It was a literal inside `dispatch`, which is why `help` and the CLI could disagree:
|
|
52
|
+
* nothing could enumerate the real list, so `--help` was a hand-maintained template and
|
|
53
|
+
* three working commands (`graph lint`, `hooks status`, `hooks run`) were never written
|
|
54
|
+
* into it. A list that cannot be read cannot be checked, and the reader of `--help` is
|
|
55
|
+
* an agent choosing a command — the one consumer with no way to discover the omission
|
|
56
|
+
* except by guessing.
|
|
57
|
+
*
|
|
58
|
+
* Module scope so `test/cli.test.mjs` can assert help against it. Module scope is also
|
|
59
|
+
* just correct: nothing in the map depends on the call.
|
|
60
|
+
*/
|
|
61
|
+
const IMPLEMENTED = {
|
|
62
|
+
doctor: runDoctor,
|
|
63
|
+
'graph lint': lintCommand,
|
|
64
|
+
'skills index': skillsIndex,
|
|
65
|
+
'skills find': skillsFind,
|
|
66
|
+
'skills brief': skillsBrief,
|
|
67
|
+
'skills show': skillsShow,
|
|
68
|
+
'skills why': skillsWhy,
|
|
69
|
+
'skills eval': skillsEval,
|
|
70
|
+
'skills install': installCommand,
|
|
71
|
+
'rules list': rulesList,
|
|
72
|
+
'rules show': rulesShow,
|
|
73
|
+
'rules why': rulesWhy,
|
|
74
|
+
sync: syncCommand,
|
|
75
|
+
check: checkCommand,
|
|
76
|
+
init: initCommand,
|
|
77
|
+
ci: ciCommand,
|
|
78
|
+
'hooks install': (args, flags) => hooksCommand('install', args, flags),
|
|
79
|
+
'hooks status': (args, flags) => hooksCommand('status', args, flags),
|
|
80
|
+
'hooks run': (args, flags) => hooksCommand('run', args, flags),
|
|
81
|
+
compare: compareCommand,
|
|
82
|
+
upgrade: upgradeCommand,
|
|
83
|
+
mcp: mcpCommand,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The dispatchable command paths, sorted. The authoritative answer to "what can this
|
|
88
|
+
* binary do", as opposed to the answer `--help` gives.
|
|
89
|
+
*/
|
|
90
|
+
export const COMMANDS = Object.keys(IMPLEMENTED).sort();
|
|
91
|
+
|
|
47
92
|
export async function dispatch(argv) {
|
|
48
93
|
const { flags, positional } = parseOrExit(argv);
|
|
49
94
|
|
|
@@ -76,29 +121,6 @@ export async function dispatch(argv) {
|
|
|
76
121
|
);
|
|
77
122
|
}
|
|
78
123
|
const args = positional.slice(offset);
|
|
79
|
-
const IMPLEMENTED = {
|
|
80
|
-
doctor: runDoctor,
|
|
81
|
-
'graph lint': lintCommand,
|
|
82
|
-
'skills index': skillsIndex,
|
|
83
|
-
'skills find': skillsFind,
|
|
84
|
-
'skills brief': skillsBrief,
|
|
85
|
-
'skills show': skillsShow,
|
|
86
|
-
'skills why': skillsWhy,
|
|
87
|
-
'skills install': installCommand,
|
|
88
|
-
'rules list': rulesList,
|
|
89
|
-
'rules show': rulesShow,
|
|
90
|
-
'rules why': rulesWhy,
|
|
91
|
-
sync: syncCommand,
|
|
92
|
-
check: checkCommand,
|
|
93
|
-
init: initCommand,
|
|
94
|
-
ci: ciCommand,
|
|
95
|
-
'hooks install': (args, flags) => hooksCommand('install', args, flags),
|
|
96
|
-
'hooks status': (args, flags) => hooksCommand('status', args, flags),
|
|
97
|
-
'hooks run': (args, flags) => hooksCommand('run', args, flags),
|
|
98
|
-
compare: compareCommand,
|
|
99
|
-
upgrade: upgradeCommand,
|
|
100
|
-
mcp: mcpCommand,
|
|
101
|
-
};
|
|
102
124
|
const run = IMPLEMENTED[path];
|
|
103
125
|
if (run) return run(args, flags);
|
|
104
126
|
return fail(`unknown command \`${path}\``, EXIT.USAGE);
|
|
@@ -117,34 +139,20 @@ function commandPath(first, second) {
|
|
|
117
139
|
if (second === undefined) return `${first} ${group}`;
|
|
118
140
|
// Reject a second token that is not a subcommand of this group, rather than
|
|
119
141
|
// silently routing `simt-dev skills nonsense` to the default subcommand.
|
|
142
|
+
//
|
|
143
|
+
// Read from IMPLEMENTED rather than from a second list. The list existed because
|
|
144
|
+
// there was no way to enumerate what dispatch could run — `IMPLEMENTED` was an
|
|
145
|
+
// object literal inside `dispatch()` — and a hand-kept copy of it had already
|
|
146
|
+
// drifted: it carried `doctor`, which `commandPath` can never match, because
|
|
147
|
+
// `doctor` is not a group and this branch is only reached when `GROUPS.has(first)`.
|
|
148
|
+
// Two lists of the same fact is the defect S15 was about in the help text.
|
|
120
149
|
const key = `${first} ${second}`;
|
|
121
|
-
if (!STEPS.has(key) && !
|
|
150
|
+
if (!STEPS.has(key) && !(key in IMPLEMENTED)) {
|
|
122
151
|
return null;
|
|
123
152
|
}
|
|
124
153
|
return key;
|
|
125
154
|
}
|
|
126
155
|
|
|
127
|
-
/**
|
|
128
|
-
* Subcommands implemented now, so they must not be rejected as unknown.
|
|
129
|
-
* Implemented paths live here; declared-but-unimplemented ones live in STEPS.
|
|
130
|
-
*/
|
|
131
|
-
const KNOWN_SUBS = new Set([
|
|
132
|
-
'doctor',
|
|
133
|
-
'graph lint',
|
|
134
|
-
'skills index',
|
|
135
|
-
'skills find',
|
|
136
|
-
'skills brief',
|
|
137
|
-
'skills show',
|
|
138
|
-
'skills why',
|
|
139
|
-
'skills install',
|
|
140
|
-
'rules list',
|
|
141
|
-
'rules show',
|
|
142
|
-
'rules why',
|
|
143
|
-
'hooks install',
|
|
144
|
-
'hooks status',
|
|
145
|
-
'hooks run',
|
|
146
|
-
]);
|
|
147
|
-
|
|
148
156
|
function parseOrExit(argv) {
|
|
149
157
|
const parsed = parse(argv);
|
|
150
158
|
if (!parsed.ok) return { flags: {}, positional: [] };
|