@mmnto/cli 2.0.0 → 2.2.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/dist/commands/describe.d.ts +13 -1
- package/dist/commands/describe.d.ts.map +1 -1
- package/dist/commands/describe.js +29 -1
- package/dist/commands/describe.js.map +1 -1
- package/dist/commands/describe.test.js +86 -3
- package/dist/commands/describe.test.js.map +1 -1
- package/dist/commands/doctor-parity-fetch.d.ts +93 -2
- package/dist/commands/doctor-parity-fetch.d.ts.map +1 -1
- package/dist/commands/doctor-parity-fetch.js +332 -11
- package/dist/commands/doctor-parity-fetch.js.map +1 -1
- package/dist/commands/doctor-parity-fetch.test.d.ts +6 -0
- package/dist/commands/doctor-parity-fetch.test.d.ts.map +1 -1
- package/dist/commands/doctor-parity-fetch.test.js +405 -1
- package/dist/commands/doctor-parity-fetch.test.js.map +1 -1
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +27 -1
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +67 -0
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/eject-gate-needle.test.d.ts +2 -0
- package/dist/commands/eject-gate-needle.test.d.ts.map +1 -0
- package/dist/commands/eject-gate-needle.test.js +48 -0
- package/dist/commands/eject-gate-needle.test.js.map +1 -0
- package/dist/commands/eject.d.ts +10 -0
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +67 -12
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +113 -0
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/gate-install.d.ts +37 -5
- package/dist/commands/gate-install.d.ts.map +1 -1
- package/dist/commands/gate-install.js +26 -38
- package/dist/commands/gate-install.js.map +1 -1
- package/dist/commands/gate-install.test.js +294 -48
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/gate.d.ts +24 -9
- package/dist/commands/gate.d.ts.map +1 -1
- package/dist/commands/gate.js +36 -17
- package/dist/commands/gate.js.map +1 -1
- package/dist/commands/gate.test.js +74 -3
- package/dist/commands/gate.test.js.map +1 -1
- package/dist/commands/host-hooks.d.ts +8 -2
- package/dist/commands/host-hooks.d.ts.map +1 -1
- package/dist/commands/host-hooks.js.map +1 -1
- package/dist/commands/init-templates.d.ts +3 -3
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +107 -48
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +20 -16
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +159 -27
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +460 -12
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/legs.d.ts +35 -7
- package/dist/commands/legs.d.ts.map +1 -1
- package/dist/commands/legs.js +26 -7
- package/dist/commands/legs.js.map +1 -1
- package/dist/commands/legs.test.js +87 -0
- package/dist/commands/legs.test.js.map +1 -1
- package/dist/commands/release-train-shape.test.d.ts +2 -0
- package/dist/commands/release-train-shape.test.d.ts.map +1 -0
- package/dist/commands/release-train-shape.test.js +226 -0
- package/dist/commands/release-train-shape.test.js.map +1 -0
- package/dist/commands/tools-hook-parity.test.js +200 -0
- package/dist/commands/tools-hook-parity.test.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -3,10 +3,10 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
-
import {
|
|
6
|
+
import { knownGates, TotemError } from '@mmnto/totem';
|
|
7
7
|
import { ejectCommand } from './eject.js';
|
|
8
|
-
import { gateInstallCommand
|
|
9
|
-
import { commandInstallsGate, GATE_WRAPPER_REL, installGates } from './gate-install.js';
|
|
8
|
+
import { gateInstallCommand } from './gate.js';
|
|
9
|
+
import { commandInstallsGate, GATE_WRAPPER_REL, installGates, } from './gate-install.js';
|
|
10
10
|
import { initCommand } from './init.js';
|
|
11
11
|
import { CLAUDE_GATE_WRAPPER, TOTEM_FILE_END, TOTEM_FILE_MARKER } from './init-templates.js';
|
|
12
12
|
import { resolveGitRootForHookPath, resolveHooksDir } from './install-hooks.js';
|
|
@@ -60,13 +60,41 @@ function preToolUseEntries(cwd) {
|
|
|
60
60
|
const hooks = (parsed.hooks ?? {});
|
|
61
61
|
return (hooks.PreToolUse ?? []);
|
|
62
62
|
}
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* The `{event, matcher}` pairs the caller resolves from the core registry and
|
|
65
|
+
* hands `installGates` (mmnto-ai/totem#2799). Spelled literally here rather than
|
|
66
|
+
* read from `knownGates()` so a registry change that silently moved a gate to a
|
|
67
|
+
* different matcher would FAIL these tests instead of following them.
|
|
68
|
+
*/
|
|
69
|
+
const FREEZE_CHECK = { event: 'freeze-check', matcher: 'Write|Edit' };
|
|
70
|
+
const TRANSPORT_SHIELD = {
|
|
71
|
+
event: 'transport-shield',
|
|
72
|
+
matcher: 'Bash|PowerShell',
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Every PreToolUse matcher an entry installing `event` currently appears under.
|
|
76
|
+
* Matcher-AGNOSTIC by construction: a gate that leaked into a second matcher
|
|
77
|
+
* shows up here as two members, which is the invariant the #2799 tests assert.
|
|
78
|
+
*/
|
|
79
|
+
function matchersFor(cwd, event) {
|
|
80
|
+
const found = [];
|
|
81
|
+
for (const e of preToolUseEntries(cwd)) {
|
|
82
|
+
if (!Array.isArray(e.hooks))
|
|
83
|
+
continue;
|
|
84
|
+
for (const h of e.hooks) {
|
|
85
|
+
const cmd = typeof h === 'string' ? h : (h?.command ?? '');
|
|
86
|
+
// Collision-safe exact --event match (tier-independent).
|
|
87
|
+
if (commandInstallsGate(cmd, event))
|
|
88
|
+
found.push(e.matcher ?? '');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
/** Count PreToolUse entries (under ANY matcher) whose command references a given gate. */
|
|
64
94
|
function gateEntryCount(cwd, event) {
|
|
65
|
-
return preToolUseEntries(cwd).filter((e) => e.
|
|
66
|
-
Array.isArray(e.hooks) &&
|
|
95
|
+
return preToolUseEntries(cwd).filter((e) => Array.isArray(e.hooks) &&
|
|
67
96
|
e.hooks.some((h) => {
|
|
68
97
|
const cmd = typeof h === 'string' ? h : (h?.command ?? '');
|
|
69
|
-
// Collision-safe exact --event match (tier-independent).
|
|
70
98
|
return commandInstallsGate(cmd, event);
|
|
71
99
|
})).length;
|
|
72
100
|
}
|
|
@@ -74,7 +102,7 @@ function gateEntryCount(cwd, event) {
|
|
|
74
102
|
function gateCommandFor(cwd, event) {
|
|
75
103
|
const cmds = [];
|
|
76
104
|
for (const e of preToolUseEntries(cwd)) {
|
|
77
|
-
if (
|
|
105
|
+
if (!Array.isArray(e.hooks))
|
|
78
106
|
continue;
|
|
79
107
|
for (const h of e.hooks) {
|
|
80
108
|
const cmd = typeof h === 'string' ? h : (h?.command ?? '');
|
|
@@ -97,7 +125,7 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
97
125
|
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
98
126
|
});
|
|
99
127
|
it('scaffolds the wrapper and merges one PreToolUse entry per gate', () => {
|
|
100
|
-
const results = installGates(cwd, [
|
|
128
|
+
const results = installGates(cwd, [FREEZE_CHECK]);
|
|
101
129
|
// wrapper scaffold + one entry merge
|
|
102
130
|
expect(results.some((r) => r.file === GATE_WRAPPER_REL && r.action === 'created')).toBe(true);
|
|
103
131
|
expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
|
|
@@ -108,7 +136,7 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
108
136
|
const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
|
|
109
137
|
fs.mkdirSync(path.dirname(wrapperPath), { recursive: true });
|
|
110
138
|
fs.writeFileSync(wrapperPath, `${TOTEM_FILE_MARKER}\nstale\n${TOTEM_FILE_END}\n`, 'utf-8');
|
|
111
|
-
const results = installGates(cwd, [
|
|
139
|
+
const results = installGates(cwd, [FREEZE_CHECK]);
|
|
112
140
|
// gate install now threads the end marker, so scaffoldFile drift-repairs the bounded
|
|
113
141
|
// wrapper (`refreshed`) and gate-install maps that to `merged` (a write happened).
|
|
114
142
|
expect(results.find((r) => r.file === GATE_WRAPPER_REL).action).toBe('merged');
|
|
@@ -116,9 +144,9 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
116
144
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
117
145
|
});
|
|
118
146
|
it('is idempotent: a second install of the same gate is a no-op', () => {
|
|
119
|
-
installGates(cwd, [
|
|
147
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
120
148
|
const before = JSON.stringify(readSettings(cwd));
|
|
121
|
-
const second = installGates(cwd, [
|
|
149
|
+
const second = installGates(cwd, [FREEZE_CHECK]);
|
|
122
150
|
const entryResult = second.find((r) => r.event === 'freeze-check');
|
|
123
151
|
expect(entryResult?.action).toBe('skipped');
|
|
124
152
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
@@ -127,8 +155,8 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
127
155
|
it('keys idempotency on the per-gate command substring (distinct gates → distinct entries)', () => {
|
|
128
156
|
// freeze-check installed; a hypothetical future gate keyed on a different
|
|
129
157
|
// --event substring produces a SECOND entry rather than colliding.
|
|
130
|
-
installGates(cwd, [
|
|
131
|
-
installGates(cwd, ['some-future-gate']);
|
|
158
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
159
|
+
installGates(cwd, [{ event: 'some-future-gate', matcher: 'Write|Edit' }]);
|
|
132
160
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
133
161
|
expect(gateEntryCount(cwd, 'some-future-gate')).toBe(1);
|
|
134
162
|
// Two distinct Write|Edit gate entries under one matcher.
|
|
@@ -147,16 +175,110 @@ describe('installGates / gate install (settings merge)', () => {
|
|
|
147
175
|
PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-hook' }] }],
|
|
148
176
|
},
|
|
149
177
|
}));
|
|
150
|
-
installGates(cwd, [
|
|
178
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
151
179
|
const entries = preToolUseEntries(cwd);
|
|
152
180
|
expect(entries.some((e) => e.matcher === 'Bash')).toBe(true);
|
|
153
181
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
154
182
|
});
|
|
155
|
-
it('gateInstallCommand --all installs every known gate', async () => {
|
|
183
|
+
it('gateInstallCommand --all installs every known gate under ITS registry matcher', async () => {
|
|
156
184
|
await gateInstallCommand({ all: true });
|
|
157
|
-
for (const event of
|
|
185
|
+
for (const { event, matcher } of knownGates()) {
|
|
186
|
+
expect(gateEntryCount(cwd, event)).toBe(1);
|
|
187
|
+
// The installed matcher is the registry's, not a shared default.
|
|
188
|
+
expect(matchersFor(cwd, event)).toEqual([matcher]);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
// ─── Per-gate matchers (mmnto-ai/totem#2799) ───────────────────────────
|
|
193
|
+
//
|
|
194
|
+
// Before #2799 every gate entry was written under one hardcoded `Write|Edit`
|
|
195
|
+
// matcher. The registry now carries each gate's own matcher, the caller
|
|
196
|
+
// resolves it (`knownGates()`), and `installGates` writes THAT — so
|
|
197
|
+
// `transport-shield` lands on `Bash|PowerShell` while `freeze-check` keeps the
|
|
198
|
+
// byte-identical `Write|Edit` entry it has always written.
|
|
199
|
+
describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
|
|
200
|
+
let cwd;
|
|
201
|
+
let originalCwd;
|
|
202
|
+
const STRICT_FREEZE_ENTRY = {
|
|
203
|
+
matcher: 'Write|Edit',
|
|
204
|
+
hooks: [
|
|
205
|
+
{
|
|
206
|
+
type: 'command',
|
|
207
|
+
command: 'node .claude/hooks/gate-wrapper.cjs --event freeze-check --strict',
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
const STRICT_TRANSPORT_ENTRY = {
|
|
212
|
+
matcher: 'Bash|PowerShell',
|
|
213
|
+
hooks: [
|
|
214
|
+
{
|
|
215
|
+
type: 'command',
|
|
216
|
+
command: 'node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict',
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
beforeEach(() => {
|
|
221
|
+
cwd = makeTmpDir();
|
|
222
|
+
originalCwd = process.cwd();
|
|
223
|
+
process.chdir(cwd);
|
|
224
|
+
});
|
|
225
|
+
afterEach(() => {
|
|
226
|
+
process.chdir(originalCwd);
|
|
227
|
+
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
228
|
+
});
|
|
229
|
+
it('transport-shield writes exactly ONE entry under Bash|PowerShell with the baked command', () => {
|
|
230
|
+
installGates(cwd, [TRANSPORT_SHIELD]);
|
|
231
|
+
expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
|
|
232
|
+
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
233
|
+
expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
|
|
234
|
+
// The whole emitted entry, byte for byte.
|
|
235
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_TRANSPORT_ENTRY]);
|
|
236
|
+
});
|
|
237
|
+
it('freeze-check writes the SAME Write|Edit entry it wrote before #2799, byte for byte', () => {
|
|
238
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
239
|
+
// The full emitted JSON entry — the regression guard on the gate whose
|
|
240
|
+
// matcher did NOT change.
|
|
241
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY]);
|
|
242
|
+
});
|
|
243
|
+
it('both gates together write both entries, each under its own matcher', () => {
|
|
244
|
+
installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
|
|
245
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY, STRICT_TRANSPORT_ENTRY]);
|
|
246
|
+
expect(matchersFor(cwd, 'freeze-check')).toEqual(['Write|Edit']);
|
|
247
|
+
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
248
|
+
});
|
|
249
|
+
it('re-running both gates at the same tier is a byte-level no-op', () => {
|
|
250
|
+
installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
|
|
251
|
+
const settingsPath = path.join(cwd, '.claude', 'settings.json');
|
|
252
|
+
const before = fs.readFileSync(settingsPath, 'utf-8');
|
|
253
|
+
const second = installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD]);
|
|
254
|
+
expect(second.find((r) => r.event === 'freeze-check')?.action).toBe('skipped');
|
|
255
|
+
expect(second.find((r) => r.event === 'transport-shield')?.action).toBe('skipped');
|
|
256
|
+
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
|
|
257
|
+
});
|
|
258
|
+
it('a tier switch updates the transport-shield entry IN PLACE under its own matcher', () => {
|
|
259
|
+
installGates(cwd, [TRANSPORT_SHIELD], 'pilot');
|
|
260
|
+
expect(gateCommandFor(cwd, 'transport-shield')).toContain('--pilot');
|
|
261
|
+
const switched = installGates(cwd, [TRANSPORT_SHIELD], 'strict');
|
|
262
|
+
expect(switched.find((r) => r.event === 'transport-shield')?.action).toBe('updated');
|
|
263
|
+
expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
|
|
264
|
+
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
265
|
+
const cmd = gateCommandFor(cwd, 'transport-shield');
|
|
266
|
+
expect(cmd).toContain('--strict');
|
|
267
|
+
expect(cmd).not.toContain('--pilot');
|
|
268
|
+
});
|
|
269
|
+
it('after any sequence of INSTALLER writes, no gate appears under two matchers (hand edits are not enforced)', () => {
|
|
270
|
+
// Interleave tiers and selections — the upsert must converge on exactly one
|
|
271
|
+
// entry per gate, always under that gate's registry matcher.
|
|
272
|
+
installGates(cwd, [FREEZE_CHECK], 'pilot');
|
|
273
|
+
installGates(cwd, [TRANSPORT_SHIELD]);
|
|
274
|
+
installGates(cwd, [TRANSPORT_SHIELD], 'pilot');
|
|
275
|
+
installGates(cwd, [FREEZE_CHECK, TRANSPORT_SHIELD], 'strict');
|
|
276
|
+
installGates(cwd, [TRANSPORT_SHIELD], 'strict');
|
|
277
|
+
for (const { event, matcher } of [FREEZE_CHECK, TRANSPORT_SHIELD]) {
|
|
278
|
+
expect(matchersFor(cwd, event)).toEqual([matcher]);
|
|
158
279
|
expect(gateEntryCount(cwd, event)).toBe(1);
|
|
159
280
|
}
|
|
281
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_FREEZE_ENTRY, STRICT_TRANSPORT_ENTRY]);
|
|
160
282
|
});
|
|
161
283
|
});
|
|
162
284
|
// ─── FIX A: tier-AWARE upsert (update in place, never silent no-op/dup) ──
|
|
@@ -179,17 +301,17 @@ describe('installGates tier-aware upsert (FIX A)', () => {
|
|
|
179
301
|
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
180
302
|
});
|
|
181
303
|
it('default(strict) then re-install --strict → skipped, one entry, command keeps --strict', () => {
|
|
182
|
-
installGates(cwd, [
|
|
183
|
-
const second = installGates(cwd, [
|
|
304
|
+
installGates(cwd, [FREEZE_CHECK]); // default tier === strict
|
|
305
|
+
const second = installGates(cwd, [FREEZE_CHECK], 'strict');
|
|
184
306
|
const entryResult = second.find((r) => r.event === 'freeze-check');
|
|
185
307
|
expect(entryResult?.action).toBe('skipped');
|
|
186
308
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
187
309
|
expect(gateCommandFor(cwd, 'freeze-check')).toContain('--strict');
|
|
188
310
|
});
|
|
189
311
|
it('--pilot then --strict → updated, ONE entry, command flips to --strict (not --pilot)', () => {
|
|
190
|
-
installGates(cwd, [
|
|
312
|
+
installGates(cwd, [FREEZE_CHECK], 'pilot');
|
|
191
313
|
expect(gateCommandFor(cwd, 'freeze-check')).toContain('--pilot');
|
|
192
|
-
const switched = installGates(cwd, [
|
|
314
|
+
const switched = installGates(cwd, [FREEZE_CHECK], 'strict');
|
|
193
315
|
const entryResult = switched.find((r) => r.event === 'freeze-check');
|
|
194
316
|
expect(entryResult?.action).toBe('updated');
|
|
195
317
|
// Exactly one entry — no duplicate created by the tier switch.
|
|
@@ -199,8 +321,8 @@ describe('installGates tier-aware upsert (FIX A)', () => {
|
|
|
199
321
|
expect(cmd).not.toContain('--pilot');
|
|
200
322
|
});
|
|
201
323
|
it('--strict then --pilot → updated back to --pilot, one entry', () => {
|
|
202
|
-
installGates(cwd, [
|
|
203
|
-
const switched = installGates(cwd, [
|
|
324
|
+
installGates(cwd, [FREEZE_CHECK], 'strict');
|
|
325
|
+
const switched = installGates(cwd, [FREEZE_CHECK], 'pilot');
|
|
204
326
|
const entryResult = switched.find((r) => r.event === 'freeze-check');
|
|
205
327
|
expect(entryResult?.action).toBe('updated');
|
|
206
328
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
@@ -242,13 +364,13 @@ describe('installGates tier-aware upsert (FIX A)', () => {
|
|
|
242
364
|
return c === shieldCommand;
|
|
243
365
|
}));
|
|
244
366
|
// Install the gate at pilot → a distinct second Write|Edit entry.
|
|
245
|
-
installGates(cwd, [
|
|
367
|
+
installGates(cwd, [FREEZE_CHECK], 'pilot');
|
|
246
368
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
247
369
|
expect(gateCommandFor(cwd, 'freeze-check')).toContain('--pilot');
|
|
248
370
|
expect(hasShield()).toBe(true);
|
|
249
371
|
expect(preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit').length).toBe(2);
|
|
250
372
|
// Tier switch → updates ONLY the gate entry in place; PreWriteShield stays.
|
|
251
|
-
const switched = installGates(cwd, [
|
|
373
|
+
const switched = installGates(cwd, [FREEZE_CHECK], 'strict');
|
|
252
374
|
expect(switched.find((r) => r.event === 'freeze-check')?.action).toBe('updated');
|
|
253
375
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
254
376
|
const cmd2 = gateCommandFor(cwd, 'freeze-check');
|
|
@@ -258,21 +380,9 @@ describe('installGates tier-aware upsert (FIX A)', () => {
|
|
|
258
380
|
expect(preToolUseEntries(cwd).filter((e) => e.matcher === 'Write|Edit').length).toBe(2);
|
|
259
381
|
});
|
|
260
382
|
});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
});
|
|
265
|
-
it('a known --<name> resolves to itself', async () => {
|
|
266
|
-
expect(await resolveGateEvents({ name: 'freeze-check' })).toEqual(['freeze-check']);
|
|
267
|
-
});
|
|
268
|
-
it('an unknown --<name> fails loud (never default-install)', async () => {
|
|
269
|
-
await expect(resolveGateEvents({ name: 'made-up-gate' })).rejects.toBeInstanceOf(TotemError);
|
|
270
|
-
await expect(resolveGateEvents({ name: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
|
|
271
|
-
});
|
|
272
|
-
it('no --all and no --<name> fails loud (no default-install)', async () => {
|
|
273
|
-
await expect(resolveGateEvents({})).rejects.toThrow(/no gate selected/i);
|
|
274
|
-
});
|
|
275
|
-
});
|
|
383
|
+
// `resolveGates` (the registry-driven `{event, matcher}` resolver that replaced
|
|
384
|
+
// `resolveGateEvents` in mmnto-ai/totem#2799) is covered in gate.test.ts, beside
|
|
385
|
+
// the other `./gate.js` command-seam tests.
|
|
276
386
|
// ─── The parameterized wrapper's disposition → exit-code map ───────────
|
|
277
387
|
//
|
|
278
388
|
// We render the wrapper template to a temp dir and drive it via stdin with
|
|
@@ -291,27 +401,60 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
291
401
|
afterEach(() => {
|
|
292
402
|
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
293
403
|
});
|
|
294
|
-
/**
|
|
404
|
+
/** Where the stub CLI records the argv and stdin it was spawned with (absent ⇒ never spawned). */
|
|
405
|
+
const stubRecordPath = () => path.join(cwd, 'stub-record.json');
|
|
406
|
+
/** Install a stub local CLI that records its argv + stdin and emits a controlled verdict / exit code. */
|
|
295
407
|
function writeStubCli(opts) {
|
|
296
408
|
const distDir = path.join(cwd, 'node_modules', '@mmnto', 'cli', 'dist');
|
|
297
409
|
fs.mkdirSync(distDir, { recursive: true });
|
|
298
410
|
const verdictJson = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
|
|
299
411
|
const exitCode = opts.exit ?? 0;
|
|
300
412
|
// CommonJS stub (the wrapper invokes via `node <path>`); .js is fine here
|
|
301
|
-
// because there is no package.json type:module in the temp dir.
|
|
413
|
+
// because there is no package.json type:module in the temp dir. The record
|
|
414
|
+
// is what lets a test assert BOTH that a spawn happened and exactly which
|
|
415
|
+
// `gate check --event … --payload -` the wrapper projected, argv AND the
|
|
416
|
+
// stdin the payload rides on (mmnto-ai/totem#2799); `JSON.stringify` on
|
|
417
|
+
// the path handles Windows separators without a hand-written escape.
|
|
302
418
|
const stub = [
|
|
303
419
|
'"use strict";',
|
|
420
|
+
'const fs = require("fs");',
|
|
304
421
|
`const out = ${JSON.stringify(verdictJson)};`,
|
|
422
|
+
'let stdin = "";',
|
|
423
|
+
'try { stdin = fs.readFileSync(0, "utf-8"); } catch { stdin = ""; }',
|
|
424
|
+
`fs.writeFileSync(${JSON.stringify(stubRecordPath())}, JSON.stringify({ argv: process.argv.slice(2), stdin }));`,
|
|
305
425
|
'if (out) process.stdout.write(out + "\\n");',
|
|
306
426
|
`process.exit(${exitCode});`,
|
|
307
427
|
'',
|
|
308
428
|
].join('\n');
|
|
309
429
|
fs.writeFileSync(path.join(distDir, 'index.js'), stub);
|
|
310
430
|
}
|
|
431
|
+
/** What the stub CLI was spawned with, or null when the wrapper never spawned it. */
|
|
432
|
+
function stubRecord() {
|
|
433
|
+
if (!fs.existsSync(stubRecordPath()))
|
|
434
|
+
return null;
|
|
435
|
+
return JSON.parse(fs.readFileSync(stubRecordPath(), 'utf-8'));
|
|
436
|
+
}
|
|
437
|
+
/** The argv the stub CLI was spawned with, or null when the wrapper never spawned it. */
|
|
438
|
+
function stubArgv() {
|
|
439
|
+
return stubRecord()?.argv ?? null;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* The payload JSON the wrapper projected, parsed (throws if it never
|
|
443
|
+
* spawned). The payload rides on the child's stdin under `--payload -` —
|
|
444
|
+
* never argv, whose win32 limit a long Bash command would exceed.
|
|
445
|
+
*/
|
|
446
|
+
function spawnedPayload() {
|
|
447
|
+
const record = stubRecord();
|
|
448
|
+
expect(record, 'the wrapper never spawned the CLI').not.toBeNull();
|
|
449
|
+
const at = record.argv.indexOf('--payload');
|
|
450
|
+
expect(at).toBeGreaterThan(-1);
|
|
451
|
+
expect(record.argv[at + 1]).toBe('-');
|
|
452
|
+
return JSON.parse(record.stdin);
|
|
453
|
+
}
|
|
311
454
|
/** Run the rendered wrapper with the given envelope + baked extra args. */
|
|
312
|
-
function runWrapper(envelope, extraArgs = []) {
|
|
455
|
+
function runWrapper(envelope, extraArgs = [], event = 'freeze-check') {
|
|
313
456
|
const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
|
|
314
|
-
const res = spawnSync(process.execPath, [wrapperPath, '--event',
|
|
457
|
+
const res = spawnSync(process.execPath, [wrapperPath, '--event', event, ...extraArgs], {
|
|
315
458
|
cwd,
|
|
316
459
|
input: JSON.stringify(envelope),
|
|
317
460
|
encoding: 'utf-8',
|
|
@@ -321,6 +464,7 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
321
464
|
}
|
|
322
465
|
const EDIT_NO_SUBSYSTEM = { tool_name: 'Edit', tool_input: { file_path: 'src/foo.ts' } };
|
|
323
466
|
const DECLARED = { tool_name: 'Edit', tool_input: { subsystem: 'rule-compilation' } };
|
|
467
|
+
const ALLOW_VERDICT = { disposition: 'allow', reason: 'ok', provenance: {} };
|
|
324
468
|
it('allow → exit 0 (silent)', () => {
|
|
325
469
|
writeStubCli({ verdict: { disposition: 'allow', reason: 'ok', provenance: {} }, exit: 0 });
|
|
326
470
|
const { status } = runWrapper(DECLARED);
|
|
@@ -417,6 +561,101 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
417
561
|
});
|
|
418
562
|
expect(res.status).toBe(0);
|
|
419
563
|
});
|
|
564
|
+
// ─── Per-event payload projection (mmnto-ai/totem#2799) ────────────────
|
|
565
|
+
//
|
|
566
|
+
// The wrapper is --event-parameterized but used to build ONE freeze-check-
|
|
567
|
+
// shaped payload for every event. It now branches on the baked --event AFTER
|
|
568
|
+
// the envelope parse and the non-object guard: each gate owns its own
|
|
569
|
+
// NOT-APPLICABLE test and its own payload, and an event it cannot project
|
|
570
|
+
// fails CLOSED. The stub CLI records its argv, so "did not spawn" is an
|
|
571
|
+
// assertion about the filesystem, not an inference from the exit code.
|
|
572
|
+
const BASH_CMD = { tool_name: 'Bash', tool_input: { command: 'git status' } };
|
|
573
|
+
const PWSH_CMD = { tool_name: 'PowerShell', tool_input: { command: 'Get-ChildItem' } };
|
|
574
|
+
it('transport-shield on a Write envelope → exit 0, never spawns', () => {
|
|
575
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
576
|
+
const { status } = runWrapper({ tool_name: 'Write', tool_input: { file_path: 'src/foo.ts' } }, [], 'transport-shield');
|
|
577
|
+
expect(status).toBe(0);
|
|
578
|
+
expect(stubArgv()).toBeNull();
|
|
579
|
+
});
|
|
580
|
+
it('transport-shield on a Write envelope that DECLARES a subsystem still never spawns', () => {
|
|
581
|
+
// The falsifier for the projection swap: pre-#2799 the wrapper keyed
|
|
582
|
+
// applicability on `tool_input.subsystem` for EVERY event, so this envelope
|
|
583
|
+
// shelled out with a freeze-check-shaped `{subsystem}` payload under
|
|
584
|
+
// `--event transport-shield`. Applicability is now the gate's own.
|
|
585
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
586
|
+
const { status } = runWrapper({ tool_name: 'Write', tool_input: { subsystem: 'rule-compilation' } }, [], 'transport-shield');
|
|
587
|
+
expect(status).toBe(0);
|
|
588
|
+
expect(stubArgv()).toBeNull();
|
|
589
|
+
});
|
|
590
|
+
it('transport-shield on a Bash envelope with no string command → exit 0, never spawns', () => {
|
|
591
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
592
|
+
const { status } = runWrapper({ tool_name: 'Bash', tool_input: { description: 'no command field' } }, [], 'transport-shield');
|
|
593
|
+
expect(status).toBe(0);
|
|
594
|
+
expect(stubArgv()).toBeNull();
|
|
595
|
+
});
|
|
596
|
+
it('transport-shield on a Bash envelope with a command spawns {tool, command, platform}', () => {
|
|
597
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
598
|
+
const { status } = runWrapper(BASH_CMD, [], 'transport-shield');
|
|
599
|
+
expect(status).toBe(0);
|
|
600
|
+
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'transport-shield', '--payload', '-']);
|
|
601
|
+
expect(spawnedPayload()).toEqual({
|
|
602
|
+
tool: 'Bash',
|
|
603
|
+
command: 'git status',
|
|
604
|
+
platform: process.platform,
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
it('a Bash command past the win32 command-line limit still reaches the gate on stdin (P3-F6)', () => {
|
|
608
|
+
// 40,000 characters: past win32's 32,767-character argv cap, where an argv
|
|
609
|
+
// payload failed the spawn with ENAMETOOLONG and fell into the fail-closed
|
|
610
|
+
// arm with nothing broken. On stdin the verdict comes back and maps to exit 0.
|
|
611
|
+
// Discriminates the fold on the windows-latest CI leg only: linux (128 KB per
|
|
612
|
+
// argument) and darwin admit a 40 KB argv, so there the pre-fold wrapper also
|
|
613
|
+
// exits 0; the stdin record assertion below is what holds on every platform.
|
|
614
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
615
|
+
/** One command-line past win32's 32,767-character cap (CreateProcess), where argv transport fails. */
|
|
616
|
+
const PAST_WIN32_ARGV_CAP = 40_000;
|
|
617
|
+
const long = 'x'.repeat(PAST_WIN32_ARGV_CAP);
|
|
618
|
+
const { status, stderr } = runWrapper({ tool_name: 'Bash', tool_input: { command: long } }, [], 'transport-shield');
|
|
619
|
+
expect(status).toBe(0);
|
|
620
|
+
expect(stderr).not.toContain('fail-closed');
|
|
621
|
+
expect(spawnedPayload()).toEqual({ tool: 'Bash', command: long, platform: process.platform });
|
|
622
|
+
});
|
|
623
|
+
it('transport-shield on a PowerShell envelope spawns the same shape with tool PowerShell', () => {
|
|
624
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
625
|
+
const { status } = runWrapper(PWSH_CMD, [], 'transport-shield');
|
|
626
|
+
expect(status).toBe(0);
|
|
627
|
+
expect(spawnedPayload()).toEqual({
|
|
628
|
+
tool: 'PowerShell',
|
|
629
|
+
command: 'Get-ChildItem',
|
|
630
|
+
platform: process.platform,
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
it('freeze-check is UNCHANGED: no subsystem → exit 0 without spawning', () => {
|
|
634
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
635
|
+
const { status } = runWrapper(EDIT_NO_SUBSYSTEM);
|
|
636
|
+
expect(status).toBe(0);
|
|
637
|
+
expect(stubArgv()).toBeNull();
|
|
638
|
+
});
|
|
639
|
+
it('freeze-check is UNCHANGED: a declared subsystem spawns the {subsystem} payload', () => {
|
|
640
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
641
|
+
const { status } = runWrapper(DECLARED);
|
|
642
|
+
expect(status).toBe(0);
|
|
643
|
+
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'freeze-check', '--payload', '-']);
|
|
644
|
+
expect(spawnedPayload()).toEqual({ subsystem: 'rule-compilation' });
|
|
645
|
+
});
|
|
646
|
+
it('an --event the wrapper cannot project → exit 2 fail-closed, never spawns', () => {
|
|
647
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
648
|
+
const { status, stderr } = runWrapper(BASH_CMD, [], 'nope');
|
|
649
|
+
expect(status).toBe(2);
|
|
650
|
+
expect(stderr).toContain('no payload projection for event "nope"; failing closed.');
|
|
651
|
+
expect(stubArgv()).toBeNull();
|
|
652
|
+
});
|
|
653
|
+
it('an unprojectable --event fails closed even under --pilot (tier maps dispositions only)', () => {
|
|
654
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
655
|
+
const { status } = runWrapper(BASH_CMD, ['--pilot'], 'nope');
|
|
656
|
+
expect(status).toBe(2);
|
|
657
|
+
expect(stubArgv()).toBeNull();
|
|
658
|
+
});
|
|
420
659
|
});
|
|
421
660
|
// ─── eject parity ──────────────────────────────────────────────────────
|
|
422
661
|
describe('eject removes the gate entry (parity with install)', () => {
|
|
@@ -433,7 +672,7 @@ describe('eject removes the gate entry (parity with install)', () => {
|
|
|
433
672
|
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
434
673
|
});
|
|
435
674
|
it('scrubs the gate PreToolUse entry and the wrapper script on eject', async () => {
|
|
436
|
-
installGates(cwd, [
|
|
675
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
437
676
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
438
677
|
expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
|
|
439
678
|
await ejectCommand({ force: true });
|
|
@@ -449,7 +688,7 @@ describe('eject removes the gate entry (parity with install)', () => {
|
|
|
449
688
|
fs.writeFileSync(path.join(cwd, '.claude', 'settings.json'), JSON.stringify({
|
|
450
689
|
hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'mine' }] }] },
|
|
451
690
|
}));
|
|
452
|
-
installGates(cwd, [
|
|
691
|
+
installGates(cwd, [FREEZE_CHECK]);
|
|
453
692
|
await ejectCommand({ force: true });
|
|
454
693
|
expect(fs.existsSync(path.join(cwd, '.claude', 'settings.json'))).toBe(true);
|
|
455
694
|
const entries = preToolUseEntries(cwd);
|
|
@@ -475,12 +714,19 @@ describe('init --gates= routes through the shared installer', () => {
|
|
|
475
714
|
expect(gateEntryCount(cwd, 'freeze-check')).toBe(1);
|
|
476
715
|
expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(true);
|
|
477
716
|
});
|
|
478
|
-
it('init --gates=all installs every known gate', async () => {
|
|
717
|
+
it('init --gates=all installs every known gate under ITS registry matcher', async () => {
|
|
479
718
|
await initCommand({ bare: true, gates: 'all' });
|
|
480
|
-
for (const event of
|
|
719
|
+
for (const { event, matcher } of knownGates()) {
|
|
481
720
|
expect(gateEntryCount(cwd, event)).toBe(1);
|
|
721
|
+
expect(matchersFor(cwd, event)).toEqual([matcher]);
|
|
482
722
|
}
|
|
483
723
|
});
|
|
724
|
+
it('init --gates=transport-shield installs it under Bash|PowerShell (the pair, not a default)', async () => {
|
|
725
|
+
await initCommand({ bare: true, gates: 'transport-shield' });
|
|
726
|
+
expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
|
|
727
|
+
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
728
|
+
expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
|
|
729
|
+
});
|
|
484
730
|
it('init --gates= with an unknown member fails loud', async () => {
|
|
485
731
|
await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
|
|
486
732
|
});
|