@mmnto/cli 2.2.0 → 2.3.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/config-drift.test.js +4 -1
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/gate-install.d.ts +35 -0
- package/dist/commands/gate-install.d.ts.map +1 -1
- package/dist/commands/gate-install.js +48 -0
- package/dist/commands/gate-install.js.map +1 -1
- package/dist/commands/gate-install.test.js +638 -9
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/gate.d.ts +9 -0
- package/dist/commands/gate.d.ts.map +1 -1
- package/dist/commands/gate.js +37 -2
- package/dist/commands/gate.js.map +1 -1
- package/dist/commands/gate.test.js +42 -1
- package/dist/commands/gate.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +8 -8
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +866 -94
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +22 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +132 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/mail-cli-wiring.test.js +58 -2
- package/dist/commands/mail-cli-wiring.test.js.map +1 -1
- package/dist/commands/mail-derive-seat.test.d.ts +28 -0
- package/dist/commands/mail-derive-seat.test.d.ts.map +1 -0
- package/dist/commands/mail-derive-seat.test.js +557 -0
- package/dist/commands/mail-derive-seat.test.js.map +1 -0
- package/dist/commands/mail.d.ts +112 -0
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +189 -0
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/resolve-threads-cli-wiring.test.d.ts +19 -0
- package/dist/commands/resolve-threads-cli-wiring.test.d.ts.map +1 -0
- package/dist/commands/resolve-threads-cli-wiring.test.js +103 -0
- package/dist/commands/resolve-threads-cli-wiring.test.js.map +1 -0
- package/dist/commands/resolve-threads.d.ts +468 -0
- package/dist/commands/resolve-threads.d.ts.map +1 -0
- package/dist/commands/resolve-threads.js +794 -0
- package/dist/commands/resolve-threads.js.map +1 -0
- package/dist/commands/resolve-threads.test.d.ts +2 -0
- package/dist/commands/resolve-threads.test.d.ts.map +1 -0
- package/dist/commands/resolve-threads.test.js +1121 -0
- package/dist/commands/resolve-threads.test.js.map +1 -0
- package/dist/commands/session-context-journal-select.test.d.ts +2 -0
- package/dist/commands/session-context-journal-select.test.d.ts.map +1 -0
- package/dist/commands/session-context-journal-select.test.js +95 -0
- package/dist/commands/session-context-journal-select.test.js.map +1 -0
- package/dist/commands/sync-labels-forms.test.d.ts +36 -0
- package/dist/commands/sync-labels-forms.test.d.ts.map +1 -0
- package/dist/commands/sync-labels-forms.test.js +573 -0
- package/dist/commands/sync-labels-forms.test.js.map +1 -0
- package/dist/index.js +74 -5
- package/dist/index.js.map +1 -1
- package/dist/parsers/bot-identity-parity.test.d.ts +2 -0
- package/dist/parsers/bot-identity-parity.test.d.ts.map +1 -0
- package/dist/parsers/bot-identity-parity.test.js +175 -0
- package/dist/parsers/bot-identity-parity.test.js.map +1 -0
- package/dist/parsers/bot-review-parser.d.ts +8 -5
- package/dist/parsers/bot-review-parser.d.ts.map +1 -1
- package/dist/parsers/bot-review-parser.js +12 -34
- package/dist/parsers/bot-review-parser.js.map +1 -1
- package/package.json +2 -2
|
@@ -51,6 +51,26 @@ function makeTmpDir() {
|
|
|
51
51
|
// `.native` expands Windows 8.3 short names so process.cwd()/realpath agree.
|
|
52
52
|
return fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-gate-install-')));
|
|
53
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The parent env with EVERY case-variant of PATH dropped and exactly one
|
|
56
|
+
* `PATH` set (win32 stores it as `Path`; a plain spread would leave both keys
|
|
57
|
+
* in the child's block). Everything else is inherited so node still boots under
|
|
58
|
+
* an empty PATH.
|
|
59
|
+
*
|
|
60
|
+
* Load-bearing since the wrapper grew a PATH fallback arm (mmnto-ai/totem#2822):
|
|
61
|
+
* a wrapper test that means "no CLI is resolvable" must say so explicitly, or it
|
|
62
|
+
* silently reads the developer's global `@mmnto/cli` and passes/fails by machine.
|
|
63
|
+
*/
|
|
64
|
+
function envWithPath(value) {
|
|
65
|
+
const env = {};
|
|
66
|
+
for (const [key, val] of Object.entries(process.env)) {
|
|
67
|
+
if (key.toUpperCase() === 'PATH')
|
|
68
|
+
continue;
|
|
69
|
+
env[key] = val;
|
|
70
|
+
}
|
|
71
|
+
env.PATH = value;
|
|
72
|
+
return env;
|
|
73
|
+
}
|
|
54
74
|
function readSettings(cwd) {
|
|
55
75
|
const raw = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf-8');
|
|
56
76
|
return JSON.parse(raw);
|
|
@@ -71,6 +91,7 @@ const TRANSPORT_SHIELD = {
|
|
|
71
91
|
event: 'transport-shield',
|
|
72
92
|
matcher: 'Bash|PowerShell',
|
|
73
93
|
};
|
|
94
|
+
const MERGE_READY = { event: 'merge-ready', matcher: 'Bash|PowerShell' };
|
|
74
95
|
/**
|
|
75
96
|
* Every PreToolUse matcher an entry installing `event` currently appears under.
|
|
76
97
|
* Matcher-AGNOSTIC by construction: a gate that leaked into a second matcher
|
|
@@ -217,6 +238,15 @@ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
|
|
|
217
238
|
},
|
|
218
239
|
],
|
|
219
240
|
};
|
|
241
|
+
const STRICT_MERGE_READY_ENTRY = {
|
|
242
|
+
matcher: 'Bash|PowerShell',
|
|
243
|
+
hooks: [
|
|
244
|
+
{
|
|
245
|
+
type: 'command',
|
|
246
|
+
command: 'node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict',
|
|
247
|
+
},
|
|
248
|
+
],
|
|
249
|
+
};
|
|
220
250
|
beforeEach(() => {
|
|
221
251
|
cwd = makeTmpDir();
|
|
222
252
|
originalCwd = process.cwd();
|
|
@@ -266,6 +296,27 @@ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
|
|
|
266
296
|
expect(cmd).toContain('--strict');
|
|
267
297
|
expect(cmd).not.toContain('--pilot');
|
|
268
298
|
});
|
|
299
|
+
it('merge-ready writes ONE entry under Bash|PowerShell, beside transport-shield (mmnto-ai/totem#2800)', () => {
|
|
300
|
+
installGates(cwd, [MERGE_READY]);
|
|
301
|
+
expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
|
|
302
|
+
expect(matchersFor(cwd, 'merge-ready')).toEqual(['Bash|PowerShell']);
|
|
303
|
+
expect(gateCommandFor(cwd, 'merge-ready')).toBe('node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict');
|
|
304
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_MERGE_READY_ENTRY]);
|
|
305
|
+
// A second Bash|PowerShell gate is its OWN entry, never folded into the
|
|
306
|
+
// first (each gate's --event is its identity).
|
|
307
|
+
installGates(cwd, [TRANSPORT_SHIELD]);
|
|
308
|
+
expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
|
|
309
|
+
expect(gateEntryCount(cwd, 'transport-shield')).toBe(1);
|
|
310
|
+
expect(preToolUseEntries(cwd)).toEqual([STRICT_MERGE_READY_ENTRY, STRICT_TRANSPORT_ENTRY]);
|
|
311
|
+
});
|
|
312
|
+
it('a merge-ready tier switch updates the one entry in place', () => {
|
|
313
|
+
installGates(cwd, [MERGE_READY], 'pilot');
|
|
314
|
+
expect(gateCommandFor(cwd, 'merge-ready')).toContain('--pilot');
|
|
315
|
+
const switched = installGates(cwd, [MERGE_READY], 'strict');
|
|
316
|
+
expect(switched.find((r) => r.event === 'merge-ready')?.action).toBe('updated');
|
|
317
|
+
expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
|
|
318
|
+
expect(gateCommandFor(cwd, 'merge-ready')).toBe('node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict');
|
|
319
|
+
});
|
|
269
320
|
it('after any sequence of INSTALLER writes, no gate appears under two matchers (hand edits are not enforced)', () => {
|
|
270
321
|
// Interleave tiers and selections — the upsert must converge on exactly one
|
|
271
322
|
// entry per gate, always under that gate's registry matcher.
|
|
@@ -407,8 +458,19 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
407
458
|
function writeStubCli(opts) {
|
|
408
459
|
const distDir = path.join(cwd, 'node_modules', '@mmnto', 'cli', 'dist');
|
|
409
460
|
fs.mkdirSync(distDir, { recursive: true });
|
|
461
|
+
// A fresh stub starts with NO record: the record is written only by a
|
|
462
|
+
// spawn, so `stubArgv() === null` after a run means "never spawned" and a
|
|
463
|
+
// `spawnedPayload()` after a run names THAT run's payload. Without this
|
|
464
|
+
// reset a loop of firing shapes that all name the same PR passed on the
|
|
465
|
+
// record left by its FIRST iteration — the PR-round-2 leg proved every
|
|
466
|
+
// later row green against the pre-fold wrapper (F2).
|
|
467
|
+
fs.rmSync(stubRecordPath(), { force: true });
|
|
410
468
|
const verdictJson = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
|
|
411
469
|
const exitCode = opts.exit ?? 0;
|
|
470
|
+
// `stderr` stands in for the ENGINE's own agent-facing lines (merge-ready's
|
|
471
|
+
// override audit, its zero-checks fact): the wrapper must pass them through
|
|
472
|
+
// in every arm, allow included (mmnto-ai/totem#2800 fold F1).
|
|
473
|
+
const stderrText = opts.stderr ?? '';
|
|
412
474
|
// CommonJS stub (the wrapper invokes via `node <path>`); .js is fine here
|
|
413
475
|
// because there is no package.json type:module in the temp dir. The record
|
|
414
476
|
// is what lets a test assert BOTH that a spawn happened and exactly which
|
|
@@ -422,6 +484,8 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
422
484
|
'let stdin = "";',
|
|
423
485
|
'try { stdin = fs.readFileSync(0, "utf-8"); } catch { stdin = ""; }',
|
|
424
486
|
`fs.writeFileSync(${JSON.stringify(stubRecordPath())}, JSON.stringify({ argv: process.argv.slice(2), stdin }));`,
|
|
487
|
+
`const err = ${JSON.stringify(stderrText)};`,
|
|
488
|
+
'if (err) process.stderr.write(err + "\\n");',
|
|
425
489
|
'if (out) process.stdout.write(out + "\\n");',
|
|
426
490
|
`process.exit(${exitCode});`,
|
|
427
491
|
'',
|
|
@@ -451,14 +515,20 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
451
515
|
expect(record.argv[at + 1]).toBe('-');
|
|
452
516
|
return JSON.parse(record.stdin);
|
|
453
517
|
}
|
|
454
|
-
/**
|
|
455
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Run the rendered wrapper with the given envelope + baked extra args.
|
|
520
|
+
* `env` defaults to the parent's (every test here writes a repo-local stub,
|
|
521
|
+
* which the wrapper resolves FIRST, so the ambient PATH is inert for them);
|
|
522
|
+
* the no-CLI test passes an empty PATH explicitly.
|
|
523
|
+
*/
|
|
524
|
+
function runWrapper(envelope, extraArgs = [], event = 'freeze-check', env = undefined) {
|
|
456
525
|
const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
|
|
457
526
|
const res = spawnSync(process.execPath, [wrapperPath, '--event', event, ...extraArgs], {
|
|
458
527
|
cwd,
|
|
459
528
|
input: JSON.stringify(envelope),
|
|
460
529
|
encoding: 'utf-8',
|
|
461
530
|
timeout: 30000,
|
|
531
|
+
...(env ? { env } : {}),
|
|
462
532
|
});
|
|
463
533
|
return { status: res.status, stderr: res.stderr ?? '' };
|
|
464
534
|
}
|
|
@@ -526,14 +596,17 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
526
596
|
expect(res.status).toBe(0);
|
|
527
597
|
});
|
|
528
598
|
// ─── FIX 2: applicable gate but no local CLI dist → fail-closed ────────
|
|
529
|
-
it('applicable gate but
|
|
530
|
-
// Deliberately do NOT write the stub CLI
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
|
|
599
|
+
it('applicable gate but NO CLI is resolvable (local absent, PATH empty) → exit 2 fail-closed (FIX 2)', () => {
|
|
600
|
+
// Deliberately do NOT write the stub CLI, and hand the wrapper an EMPTY
|
|
601
|
+
// PATH so neither resolution arm can answer (mmnto-ai/totem#2822 added the
|
|
602
|
+
// PATH fallback; without this the test would silently read a developer's
|
|
603
|
+
// global @mmnto/cli and stop testing fail-closed). A declared subsystem
|
|
604
|
+
// makes the gate APPLY; freeze-check has no commit-time hard floor, so an
|
|
605
|
+
// applicable-but-unevaluable gate must fail closed (exit 2), never silently
|
|
606
|
+
// allow (exit 0).
|
|
607
|
+
const { status, stderr } = runWrapper(DECLARED, [], 'freeze-check', envWithPath(''));
|
|
535
608
|
expect(status).toBe(2);
|
|
536
|
-
expect(stderr).toMatch(/
|
|
609
|
+
expect(stderr).toMatch(/failing closed/i);
|
|
537
610
|
});
|
|
538
611
|
it('gate check exits 0 with unparseable stdout → exit 2 fail-closed', () => {
|
|
539
612
|
// verdict undefined → stub emits nothing; the wrapper cannot parse a
|
|
@@ -597,6 +670,11 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
597
670
|
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
598
671
|
const { status } = runWrapper(BASH_CMD, [], 'transport-shield');
|
|
599
672
|
expect(status).toBe(0);
|
|
673
|
+
// The baked tier rides along since mmnto-ai/totem#2800 (R1): the engine owns
|
|
674
|
+
// the strict/pilot split for a gate's own unevaluable class.
|
|
675
|
+
// A STRICT wrapper forwards NO `--tier` (fold F3): strict is the engine's
|
|
676
|
+
// default, and an option a 2.2.x CLI cannot parse would fail the check
|
|
677
|
+
// closed — re-entering the mmnto-ai/totem#2822 bootstrap self-block.
|
|
600
678
|
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'transport-shield', '--payload', '-']);
|
|
601
679
|
expect(spawnedPayload()).toEqual({
|
|
602
680
|
tool: 'Bash',
|
|
@@ -643,6 +721,51 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
643
721
|
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'freeze-check', '--payload', '-']);
|
|
644
722
|
expect(spawnedPayload()).toEqual({ subsystem: 'rule-compilation' });
|
|
645
723
|
});
|
|
724
|
+
it('a PILOT install forwards --tier pilot; a strict one forwards none (fold F3)', () => {
|
|
725
|
+
// The tier the ENGINE needs is the non-default one. Forwarding `--tier` on
|
|
726
|
+
// every entry made a 2.2.x CLI exit "unknown option" — the fail-closed arm —
|
|
727
|
+
// for every gated command, so only pilot pays that coupling and its
|
|
728
|
+
// install-time disclosure names the 2.3.0 floor.
|
|
729
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
730
|
+
runWrapper(DECLARED, ['--pilot']);
|
|
731
|
+
expect(stubArgv()).toEqual([
|
|
732
|
+
'gate',
|
|
733
|
+
'check',
|
|
734
|
+
'--event',
|
|
735
|
+
'freeze-check',
|
|
736
|
+
'--tier',
|
|
737
|
+
'pilot',
|
|
738
|
+
'--payload',
|
|
739
|
+
'-',
|
|
740
|
+
]);
|
|
741
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
742
|
+
runWrapper(DECLARED, ['--strict']);
|
|
743
|
+
expect(stubArgv()).not.toContain('--tier');
|
|
744
|
+
});
|
|
745
|
+
it('the child CLI stderr reaches the transcript on ALLOW, not just on failure (fold F1)', () => {
|
|
746
|
+
// merge-ready writes its audited-override line and its zero-checks fact to
|
|
747
|
+
// stderr with an `allow` verdict. Printing the child's stderr only in the
|
|
748
|
+
// failure arm silently dropped exactly the lines that must never be silent.
|
|
749
|
+
writeStubCli({
|
|
750
|
+
verdict: ALLOW_VERDICT,
|
|
751
|
+
exit: 0,
|
|
752
|
+
stderr: '[totem merge-ready] OVERRIDE (TOTEM_MERGE_GATE_OVERRIDE=1): allowing repo#1',
|
|
753
|
+
});
|
|
754
|
+
const { status, stderr } = runWrapper(DECLARED);
|
|
755
|
+
expect(status).toBe(0);
|
|
756
|
+
expect(stderr).toContain('[totem merge-ready] OVERRIDE');
|
|
757
|
+
});
|
|
758
|
+
it('a warn verdict carries the child stderr through as well (fold F1)', () => {
|
|
759
|
+
writeStubCli({
|
|
760
|
+
verdict: { disposition: 'warn', reason: 'heads up', provenance: {} },
|
|
761
|
+
exit: 0,
|
|
762
|
+
stderr: '[totem merge-ready] ZERO status checks — predicate 1 passes as a fact',
|
|
763
|
+
});
|
|
764
|
+
const { status, stderr } = runWrapper(DECLARED);
|
|
765
|
+
expect(status).toBe(0);
|
|
766
|
+
expect(stderr).toContain('ZERO status checks');
|
|
767
|
+
expect(stderr).toContain('heads up');
|
|
768
|
+
});
|
|
646
769
|
it('an --event the wrapper cannot project → exit 2 fail-closed, never spawns', () => {
|
|
647
770
|
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
648
771
|
const { status, stderr } = runWrapper(BASH_CMD, [], 'nope');
|
|
@@ -656,6 +779,480 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
656
779
|
expect(status).toBe(2);
|
|
657
780
|
expect(stubArgv()).toBeNull();
|
|
658
781
|
});
|
|
782
|
+
// ─── merge-ready projection (mmnto-ai/totem#2800) ──────────────────────
|
|
783
|
+
//
|
|
784
|
+
// The gate installs under Bash|PowerShell, so this branch sees EVERY shell
|
|
785
|
+
// command: the load-bearing half is what it does NOT do — a command that is
|
|
786
|
+
// not `gh pr merge` at command position must pass through (exit 0) without
|
|
787
|
+
// ever spawning the CLI. `stubArgv() === null` is the filesystem record of
|
|
788
|
+
// "never spawned", not an inference from the exit code.
|
|
789
|
+
describe('merge-ready', () => {
|
|
790
|
+
/** Give the temp cwd a git identity so the projection can read repo/branch/head. */
|
|
791
|
+
function initGitRepo() {
|
|
792
|
+
spawnSync('git', ['init', '-b', 'feat/demo'], { cwd, encoding: 'utf-8' });
|
|
793
|
+
spawnSync('git', ['remote', 'add', 'origin', 'https://github.com/mmnto-ai/totem.git'], {
|
|
794
|
+
cwd,
|
|
795
|
+
});
|
|
796
|
+
fs.writeFileSync(path.join(cwd, 'file.txt'), 'x');
|
|
797
|
+
spawnSync('git', ['add', '-A'], { cwd });
|
|
798
|
+
spawnSync('git', ['-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-m', 'init'], { cwd });
|
|
799
|
+
return spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf-8' }).stdout.trim();
|
|
800
|
+
}
|
|
801
|
+
const bash = (command) => ({
|
|
802
|
+
tool_name: 'Bash',
|
|
803
|
+
tool_input: { command },
|
|
804
|
+
});
|
|
805
|
+
it('projects { repo, pr, headSha } from `gh pr merge <number>`', () => {
|
|
806
|
+
const head = initGitRepo();
|
|
807
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
808
|
+
const { status } = runWrapper(bash('gh pr merge 2800 --squash'), [], 'merge-ready');
|
|
809
|
+
expect(status).toBe(0);
|
|
810
|
+
// No `--tier` at the default strict tier (fold F3).
|
|
811
|
+
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'merge-ready', '--payload', '-']);
|
|
812
|
+
expect(spawnedPayload()).toEqual({ repo: 'mmnto-ai/totem', pr: 2800, headSha: head });
|
|
813
|
+
});
|
|
814
|
+
it('projects pr: null + the CURRENT branch when the command names no PR', () => {
|
|
815
|
+
initGitRepo();
|
|
816
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
817
|
+
runWrapper(bash('gh pr merge'), [], 'merge-ready');
|
|
818
|
+
const payload = spawnedPayload();
|
|
819
|
+
expect(payload.pr).toBeNull();
|
|
820
|
+
expect(payload.branch).toBe('feat/demo');
|
|
821
|
+
expect(payload.repo).toBe('mmnto-ai/totem');
|
|
822
|
+
});
|
|
823
|
+
it('reads the repo from -R / --repo= and the PR from a URL, over the git remote', () => {
|
|
824
|
+
initGitRepo();
|
|
825
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
826
|
+
runWrapper(bash('gh pr merge --squash -R mmnto-ai/liquid-city 363'), [], 'merge-ready');
|
|
827
|
+
expect(spawnedPayload()).toMatchObject({ repo: 'mmnto-ai/liquid-city', pr: 363 });
|
|
828
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
829
|
+
runWrapper(bash('gh pr merge https://github.com/mmnto-ai/totem-strategy/pull/1251 --merge'), [], 'merge-ready');
|
|
830
|
+
expect(spawnedPayload()).toMatchObject({ repo: 'mmnto-ai/totem-strategy', pr: 1251 });
|
|
831
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
832
|
+
runWrapper(bash('gh pr merge 12 --repo=mmnto-ai/other'), [], 'merge-ready');
|
|
833
|
+
expect(spawnedPayload()).toMatchObject({ repo: 'mmnto-ai/other', pr: 12 });
|
|
834
|
+
});
|
|
835
|
+
it('a named branch is projected as the branch, not as a PR number', () => {
|
|
836
|
+
initGitRepo();
|
|
837
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
838
|
+
runWrapper(bash('gh pr merge feat/other-branch'), [], 'merge-ready');
|
|
839
|
+
expect(spawnedPayload()).toMatchObject({ pr: null, branch: 'feat/other-branch' });
|
|
840
|
+
});
|
|
841
|
+
it('a value-taking flag does not swallow the PR target (`-b "…" 42`)', () => {
|
|
842
|
+
initGitRepo();
|
|
843
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
844
|
+
runWrapper(bash('gh pr merge -b "merge this now" 42'), [], 'merge-ready');
|
|
845
|
+
expect(spawnedPayload()).toMatchObject({ pr: 42 });
|
|
846
|
+
});
|
|
847
|
+
it("fires at command position after a separator, after the shell's command-position words, and behind an assignment prefix", () => {
|
|
848
|
+
initGitRepo();
|
|
849
|
+
for (const command of [
|
|
850
|
+
'git status && gh pr merge 7',
|
|
851
|
+
'git fetch; gh pr merge 7',
|
|
852
|
+
'for x in 1; do gh pr merge 7; done',
|
|
853
|
+
'if true; then gh pr merge 7; fi',
|
|
854
|
+
'git log |\ngh pr merge 7',
|
|
855
|
+
// PR round 1 (greptile): a merge used AS the condition, and one behind
|
|
856
|
+
// an assignment prefix, each left something other than `gh` at the
|
|
857
|
+
// segment's front and went unjudged.
|
|
858
|
+
'if gh pr merge 7; then echo merged; fi',
|
|
859
|
+
'if false; then :; elif gh pr merge 7; then :; fi',
|
|
860
|
+
'while gh pr merge 7; do break; done',
|
|
861
|
+
'until gh pr merge 7; do sleep 1; done',
|
|
862
|
+
'GH_TOKEN=x gh pr merge 7',
|
|
863
|
+
'GH_REPO=mmnto-ai/totem GH_TOKEN="a b" gh pr merge 7',
|
|
864
|
+
'exec gh pr merge 7',
|
|
865
|
+
'command gh pr merge 7',
|
|
866
|
+
// Round 2 (the leg's F1): two more reserved words and the builtin that
|
|
867
|
+
// runs an unquoted operand as the command.
|
|
868
|
+
'time gh pr merge 7',
|
|
869
|
+
'coproc gh pr merge 7',
|
|
870
|
+
'eval gh pr merge 7',
|
|
871
|
+
]) {
|
|
872
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
873
|
+
runWrapper(bash(command), [], 'merge-ready');
|
|
874
|
+
expect(spawnedPayload(), command).toMatchObject({ pr: 7 });
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
it('does NOT fire inside a quoted string, a heredoc body, or on another gh verb — and never spawns', () => {
|
|
878
|
+
initGitRepo();
|
|
879
|
+
for (const command of [
|
|
880
|
+
'echo "gh pr merge 5"',
|
|
881
|
+
"echo 'gh pr merge 5'",
|
|
882
|
+
'gh pr list',
|
|
883
|
+
'gh pr view 3 | grep merge',
|
|
884
|
+
'git commit -m "gh pr merge"',
|
|
885
|
+
// A heredoc body is DATA, not commands (fold F4): firing here was a
|
|
886
|
+
// false deny — the direction this projection must not have.
|
|
887
|
+
'cat <<EOF\ngh pr merge 5\nEOF',
|
|
888
|
+
"cat <<'EOF'\ngh pr merge 5\nEOF",
|
|
889
|
+
'cat <<-EOF\n\tgh pr merge 5\n\tEOF',
|
|
890
|
+
'cat <<EOF > notes.txt\ngh pr merge 5\nEOF\necho done',
|
|
891
|
+
// An UNTERMINATED body runs to the end of the command and is still data.
|
|
892
|
+
'cat <<EOF\ngh pr merge 5',
|
|
893
|
+
// DISCLOSED misses (the gate does not fire — the safe direction): a
|
|
894
|
+
// wrapper PROGRAM takes the first token, so the position anchor never
|
|
895
|
+
// sees `gh` (an assignment prefix no longer hides it — PR round 1).
|
|
896
|
+
'sudo gh pr merge 5',
|
|
897
|
+
'timeout 30 gh pr merge 5',
|
|
898
|
+
'env GH_TOKEN=x gh pr merge 5',
|
|
899
|
+
// Round 2 (the leg's F1/F3), disclosed in the template's comment: a
|
|
900
|
+
// merge handed over as ONE quoted word, a builtin with a flag before
|
|
901
|
+
// `gh`, a backtick substitution, a leading redirection.
|
|
902
|
+
'eval "gh pr merge 5"',
|
|
903
|
+
'command -p gh pr merge 5',
|
|
904
|
+
'exec -a x gh pr merge 5',
|
|
905
|
+
// Round 3 (the leg's F1): the reserved word carrying its own flag.
|
|
906
|
+
'time -p gh pr merge 5',
|
|
907
|
+
'time -- gh pr merge 5',
|
|
908
|
+
'echo `gh pr merge 5`',
|
|
909
|
+
'> out.txt gh pr merge 5',
|
|
910
|
+
]) {
|
|
911
|
+
writeStubCli({
|
|
912
|
+
verdict: { disposition: 'deny', reason: 'should not run', provenance: {} },
|
|
913
|
+
});
|
|
914
|
+
const { status } = runWrapper(bash(command), [], 'merge-ready');
|
|
915
|
+
expect(status, command).toBe(0);
|
|
916
|
+
expect(stubArgv(), command).toBeNull();
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
it('every gh pr merge at command position is judged, not only the first (PR round 1, greptile)', () => {
|
|
920
|
+
initGitRepo();
|
|
921
|
+
// The stub overwrites its record on every spawn, so the record names the
|
|
922
|
+
// LAST payload judged. An allowing stub: the second merge is reached.
|
|
923
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
924
|
+
const allowed = runWrapper(bash('gh pr merge 7; gh pr merge 8'), [], 'merge-ready');
|
|
925
|
+
expect(allowed.status).toBe(0);
|
|
926
|
+
expect(spawnedPayload()).toMatchObject({ pr: 8 });
|
|
927
|
+
// A denying stub under strict: the FIRST deny exits 2, so the second
|
|
928
|
+
// merge is never spawned and the record still names the first.
|
|
929
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'not ready', provenance: {} } });
|
|
930
|
+
const denied = runWrapper(bash('gh pr merge 7 && gh pr merge 8'), [], 'merge-ready');
|
|
931
|
+
expect(denied.status).toBe(2);
|
|
932
|
+
expect(spawnedPayload()).toMatchObject({ pr: 7 });
|
|
933
|
+
expect(denied.stderr.match(/merge-ready \(deny\)/g)).toHaveLength(1);
|
|
934
|
+
// Under --pilot a deny prints and the NEXT merge is still judged: two
|
|
935
|
+
// deny lines, the record names the second merge, exit 0.
|
|
936
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'not ready', provenance: {} } });
|
|
937
|
+
const pilot = runWrapper(bash('gh pr merge 7; gh pr merge 8'), ['--pilot'], 'merge-ready');
|
|
938
|
+
expect(pilot.status).toBe(0);
|
|
939
|
+
expect(spawnedPayload()).toMatchObject({ pr: 8 });
|
|
940
|
+
expect(pilot.stderr.match(/merge-ready \(deny\)/g)).toHaveLength(2);
|
|
941
|
+
});
|
|
942
|
+
it('an arithmetic shift or a comment does not swallow the merge that follows (fold round 2, F1)', () => {
|
|
943
|
+
// `$((1<<2))` is a SHIFT and `# see <<note` is a comment: neither opens a
|
|
944
|
+
// heredoc. Before the guards, each swallowed the rest of the command and
|
|
945
|
+
// the real merge after it went unjudged — a silent miss on the exact
|
|
946
|
+
// command this gate exists for.
|
|
947
|
+
initGitRepo();
|
|
948
|
+
for (const command of [
|
|
949
|
+
'echo $((1<<2)); gh pr merge 5',
|
|
950
|
+
'echo $(( 3<<1 )); gh pr merge 5',
|
|
951
|
+
'# see <<note\ngh pr merge 5',
|
|
952
|
+
'(( 1<<3 ))\ngh pr merge 5',
|
|
953
|
+
'echo hi # <<EOF\ngh pr merge 5',
|
|
954
|
+
]) {
|
|
955
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
956
|
+
runWrapper(bash(command), [], 'merge-ready');
|
|
957
|
+
expect(spawnedPayload(), command).toMatchObject({ pr: 5 });
|
|
958
|
+
}
|
|
959
|
+
});
|
|
960
|
+
it('a line continuation does not hide the anchor (round 3, F6)', () => {
|
|
961
|
+
// The shell removes a backslash-newline and joins the halves. Absorbing
|
|
962
|
+
// the newline into the token left the segment starting with something
|
|
963
|
+
// other than `gh`, so a real merge went unjudged.
|
|
964
|
+
initGitRepo();
|
|
965
|
+
for (const command of ['gh \\\npr merge 5', 'gh pr merge \\\n5', 'gh \\\r\npr merge 5']) {
|
|
966
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
967
|
+
runWrapper(bash(command), [], 'merge-ready');
|
|
968
|
+
expect(spawnedPayload(), JSON.stringify(command)).toMatchObject({ pr: 5 });
|
|
969
|
+
}
|
|
970
|
+
// A backslash before an ORDINARY character keeps its old meaning: it
|
|
971
|
+
// escapes that character into the token.
|
|
972
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
973
|
+
runWrapper(bash('gh pr merge 5 --body a\\ b'), [], 'merge-ready');
|
|
974
|
+
expect(spawnedPayload()).toMatchObject({ pr: 5 });
|
|
975
|
+
});
|
|
976
|
+
it('a PowerShell block comment is data, not commands (round 3 F8; round 4 F1, F8)', () => {
|
|
977
|
+
initGitRepo();
|
|
978
|
+
const pwsh = (command) => ({
|
|
979
|
+
tool_name: 'PowerShell',
|
|
980
|
+
tool_input: { command },
|
|
981
|
+
});
|
|
982
|
+
// THE CONTROL (round 4, F1): a MULTI-LINE block comment. Without the
|
|
983
|
+
// `<#` arm this fires with pr 9 — verified by stripping the arm from a
|
|
984
|
+
// rendered copy. The single-line row below behaves the same either way
|
|
985
|
+
// (the `#` word-comment arm already covers it), so it is a companion, not
|
|
986
|
+
// a control.
|
|
987
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
988
|
+
expect(runWrapper(pwsh('<#\ngh pr merge 9\n#>\necho hi'), [], 'merge-ready').status).toBe(0);
|
|
989
|
+
expect(stubArgv()).toBeNull();
|
|
990
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
991
|
+
expect(runWrapper(pwsh('<# gh pr merge 9 #>\necho hi'), [], 'merge-ready').status).toBe(0);
|
|
992
|
+
expect(stubArgv()).toBeNull();
|
|
993
|
+
// The merge AFTER one is still judged — the blank must not eat it.
|
|
994
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
995
|
+
runWrapper(pwsh('<# notes #>\ngh pr merge 4'), [], 'merge-ready');
|
|
996
|
+
expect(spawnedPayload()).toMatchObject({ pr: 4 });
|
|
997
|
+
// ROUND 4 F8: the blank is a POWERSHELL rule. In bash `<#tmp` is a
|
|
998
|
+
// redirect from a file named `#tmp`, and blanking from it to a later `#>`
|
|
999
|
+
// would swallow the real merge on the next line.
|
|
1000
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
1001
|
+
runWrapper(bash('sort <#tmp\ngh pr merge 8'), [], 'merge-ready');
|
|
1002
|
+
expect(spawnedPayload()).toMatchObject({ pr: 8 });
|
|
1003
|
+
});
|
|
1004
|
+
it('a comment is not a command: a merge inside one never fires', () => {
|
|
1005
|
+
initGitRepo();
|
|
1006
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
1007
|
+
const { status } = runWrapper(bash('echo hi # gh pr merge 9'), [], 'merge-ready');
|
|
1008
|
+
expect(status).toBe(0);
|
|
1009
|
+
expect(stubArgv()).toBeNull();
|
|
1010
|
+
});
|
|
1011
|
+
it('EVERY heredoc queued on a line is read as data, not just the first (fold round 2, F2)', () => {
|
|
1012
|
+
// bash reads `cat <<A <<B` as two bodies in order, so a command sitting
|
|
1013
|
+
// in B's body is data too. Consuming only A's body left B's body as
|
|
1014
|
+
// commands — a false deny on text.
|
|
1015
|
+
initGitRepo();
|
|
1016
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
1017
|
+
const { status } = runWrapper(bash('cat <<A <<B\nfirst\nA\ngh pr merge 5\nB\n'), [], 'merge-ready');
|
|
1018
|
+
expect(status).toBe(0);
|
|
1019
|
+
expect(stubArgv()).toBeNull();
|
|
1020
|
+
// The complement: a heredoc as the merge's OWN operand still fires.
|
|
1021
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
1022
|
+
runWrapper(bash('gh pr merge 5 <<EOF\nnotes\nEOF\n'), [], 'merge-ready');
|
|
1023
|
+
expect(spawnedPayload()).toMatchObject({ pr: 5 });
|
|
1024
|
+
});
|
|
1025
|
+
it('still fires on a real merge that FOLLOWS a heredoc (the blanker keeps the segments)', () => {
|
|
1026
|
+
// The heredoc blanker must not swallow the rest of the command: the
|
|
1027
|
+
// terminator line ends the body and the next segment is judged normally.
|
|
1028
|
+
initGitRepo();
|
|
1029
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
1030
|
+
runWrapper(bash('cat <<EOF > body.md\nsome release notes\nEOF\ngh pr merge 21'), [], 'merge-ready');
|
|
1031
|
+
expect(spawnedPayload()).toMatchObject({ pr: 21 });
|
|
1032
|
+
});
|
|
1033
|
+
it('an unexpanded shell variable rides as unresolvedTarget, not as a branch (fold F13)', () => {
|
|
1034
|
+
// `gh pr merge $PR` names a target the hook cannot know — the shell
|
|
1035
|
+
// expands it after the gate has already decided. Reading "$PR" as a
|
|
1036
|
+
// branch would judge the wrong PR, or none.
|
|
1037
|
+
initGitRepo();
|
|
1038
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
1039
|
+
runWrapper(bash('gh pr merge $PR --squash'), [], 'merge-ready');
|
|
1040
|
+
const payload = spawnedPayload();
|
|
1041
|
+
expect(payload.unresolvedTarget).toBe('$PR');
|
|
1042
|
+
expect(payload.pr).toBeNull();
|
|
1043
|
+
// NOT the current-branch fallback: that is for a command naming no target.
|
|
1044
|
+
expect(payload.branch).toBeUndefined();
|
|
1045
|
+
});
|
|
1046
|
+
it('a braced expansion keeps its braces in the evidence (round 2, F10)', () => {
|
|
1047
|
+
// `${PR}` used to reach the engine as bare "$", because the tokenizer
|
|
1048
|
+
// split on the braces. A `$( … )` deliberately still splits — a real
|
|
1049
|
+
// merge inside a command substitution must keep firing.
|
|
1050
|
+
initGitRepo();
|
|
1051
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
1052
|
+
runWrapper(bash('gh pr merge ${PR}'), [], 'merge-ready');
|
|
1053
|
+
expect(spawnedPayload().unresolvedTarget).toBe('${PR}');
|
|
1054
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
1055
|
+
runWrapper(bash('echo $(gh pr merge 8)'), [], 'merge-ready');
|
|
1056
|
+
expect(spawnedPayload()).toMatchObject({ pr: 8 });
|
|
1057
|
+
});
|
|
1058
|
+
it('a Write envelope and a PowerShell command are treated by tool, not by text', () => {
|
|
1059
|
+
initGitRepo();
|
|
1060
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
|
|
1061
|
+
const write = runWrapper({ tool_name: 'Write', tool_input: { file_path: 'notes.md' } }, [], 'merge-ready');
|
|
1062
|
+
expect(write.status).toBe(0);
|
|
1063
|
+
expect(stubArgv()).toBeNull();
|
|
1064
|
+
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
1065
|
+
runWrapper({ tool_name: 'PowerShell', tool_input: { command: 'gh pr merge 11' } }, [], 'merge-ready');
|
|
1066
|
+
expect(spawnedPayload()).toMatchObject({ pr: 11 });
|
|
1067
|
+
});
|
|
1068
|
+
it('an evaluation failure on an APPLICABLE merge blocks (fail-closed), pilot exits 0', () => {
|
|
1069
|
+
initGitRepo();
|
|
1070
|
+
writeStubCli({ exit: 1 });
|
|
1071
|
+
const strict = runWrapper(bash('gh pr merge 2800'), [], 'merge-ready');
|
|
1072
|
+
expect(strict.status).toBe(2);
|
|
1073
|
+
expect(strict.stderr).toMatch(/fail-closed/i);
|
|
1074
|
+
writeStubCli({ verdict: { disposition: 'deny', reason: 'behind', provenance: {} }, exit: 0 });
|
|
1075
|
+
const pilot = runWrapper(bash('gh pr merge 2800'), ['--pilot'], 'merge-ready');
|
|
1076
|
+
expect(pilot.status).toBe(0);
|
|
1077
|
+
expect(pilot.stderr).toContain('behind');
|
|
1078
|
+
});
|
|
1079
|
+
});
|
|
1080
|
+
});
|
|
1081
|
+
// ─── The PATH fallback arm (mmnto-ai/totem#2822) ───────────────────────
|
|
1082
|
+
//
|
|
1083
|
+
// A `Bash|PowerShell`-matched gate applies to `pnpm install` / `pnpm build` —
|
|
1084
|
+
// the very commands that CREATE the repo-local CLI — so a repo-local-ONLY
|
|
1085
|
+
// resolution made the gate block its own bootstrap on a fresh clone, and block
|
|
1086
|
+
// the cure its message named (`totem eject`, a Bash command). The wrapper now
|
|
1087
|
+
// falls back to a `totem` on PATH (repo-local still FIRST) and fails closed
|
|
1088
|
+
// only when NEITHER arm resolves — the #2799 ruling is kept, the self-block is
|
|
1089
|
+
// not. Each test below drives the rendered wrapper with a CONTROLLED PATH and
|
|
1090
|
+
// discriminates the fold: pre-fix, the first two exited through the no-CLI arm
|
|
1091
|
+
// (there was no PATH probe at all) and the third's message named `totem eject`.
|
|
1092
|
+
describe('gate-wrapper.cjs PATH fallback (mmnto-ai/totem#2822)', () => {
|
|
1093
|
+
let cwd;
|
|
1094
|
+
let pathDir;
|
|
1095
|
+
/** The self-block case: a bootstrap command under the shell-matched gate. */
|
|
1096
|
+
const BOOTSTRAP = { tool_name: 'Bash', tool_input: { command: 'pnpm install' } };
|
|
1097
|
+
const ALLOW = { disposition: 'allow', reason: 'ok', provenance: {} };
|
|
1098
|
+
beforeEach(() => {
|
|
1099
|
+
cwd = makeTmpDir();
|
|
1100
|
+
fs.mkdirSync(path.join(cwd, '.claude', 'hooks'), { recursive: true });
|
|
1101
|
+
fs.writeFileSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'), CLAUDE_GATE_WRAPPER);
|
|
1102
|
+
// A PATH entry shaped like an npm global prefix on win32: the `totem.cmd`
|
|
1103
|
+
// shim's sibling `node_modules/@mmnto/cli/dist/index.js`. Deliberately NOT
|
|
1104
|
+
// under `cwd/node_modules`, so the repo-local arm cannot resolve it.
|
|
1105
|
+
pathDir = path.join(cwd, 'global-bin');
|
|
1106
|
+
fs.mkdirSync(pathDir, { recursive: true });
|
|
1107
|
+
});
|
|
1108
|
+
afterEach(() => {
|
|
1109
|
+
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
1110
|
+
});
|
|
1111
|
+
/**
|
|
1112
|
+
* A CLI stub at `<dir>/node_modules/@mmnto/cli/dist/index.js` emitting the
|
|
1113
|
+
* given verdict and exit code; returns the entry path the wrapper should
|
|
1114
|
+
* resolve. It drains stdin (the `--payload -` channel) before exiting so the
|
|
1115
|
+
* parent's `input` write never races an EPIPE into `result.error`.
|
|
1116
|
+
*/
|
|
1117
|
+
function writeCliUnder(dir, opts) {
|
|
1118
|
+
const distDir = path.join(dir, 'node_modules', '@mmnto', 'cli', 'dist');
|
|
1119
|
+
fs.mkdirSync(distDir, { recursive: true });
|
|
1120
|
+
const out = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
|
|
1121
|
+
const stub = [
|
|
1122
|
+
'"use strict";',
|
|
1123
|
+
'const fs = require("fs");',
|
|
1124
|
+
`const out = ${JSON.stringify(out)};`,
|
|
1125
|
+
'try { fs.readFileSync(0, "utf-8"); } catch { /* no stdin */ }',
|
|
1126
|
+
'if (out) process.stdout.write(out + "\\n");',
|
|
1127
|
+
`process.exit(${opts.exit ?? 0});`,
|
|
1128
|
+
'',
|
|
1129
|
+
].join('\n');
|
|
1130
|
+
const entry = path.join(distDir, 'index.js');
|
|
1131
|
+
fs.writeFileSync(entry, stub);
|
|
1132
|
+
return entry;
|
|
1133
|
+
}
|
|
1134
|
+
/** Run the rendered wrapper in `cwd` with an exact PATH value. */
|
|
1135
|
+
function runWithPath(envelope, pathValue, event = 'transport-shield') {
|
|
1136
|
+
const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
|
|
1137
|
+
const res = spawnSync(process.execPath, [wrapperPath, '--event', event, '--strict'], {
|
|
1138
|
+
cwd,
|
|
1139
|
+
input: JSON.stringify(envelope),
|
|
1140
|
+
encoding: 'utf-8',
|
|
1141
|
+
timeout: 30000,
|
|
1142
|
+
env: envWithPath(pathValue),
|
|
1143
|
+
});
|
|
1144
|
+
return { status: res.status, stderr: res.stderr ?? '' };
|
|
1145
|
+
}
|
|
1146
|
+
it('no repo-local CLI but a totem on PATH → the PATH arm evaluates (allow → exit 0)', () => {
|
|
1147
|
+
writeCliUnder(pathDir, { verdict: ALLOW, exit: 0 });
|
|
1148
|
+
const { status } = runWithPath(BOOTSTRAP, pathDir);
|
|
1149
|
+
// Pre-fix this was exit 2: the wrapper never probed PATH, so a fresh clone's
|
|
1150
|
+
// `pnpm install` was blocked by the gate it was about to install the CLI for.
|
|
1151
|
+
expect(status).toBe(0);
|
|
1152
|
+
});
|
|
1153
|
+
it('the PATH CLI failing → exit 2 fail-closed, disclosing which CLI evaluated (basenames only)', () => {
|
|
1154
|
+
// A CLI older than 2.2.0 has no `gate check --payload -` and lands exactly
|
|
1155
|
+
// here (unknown option → non-zero exit). Fail-closed is UNCHANGED; the
|
|
1156
|
+
// added line names the arm so the operator knows what to update.
|
|
1157
|
+
const entry = writeCliUnder(pathDir, { exit: 1 });
|
|
1158
|
+
const { status, stderr } = runWithPath(BOOTSTRAP, pathDir);
|
|
1159
|
+
expect(status).toBe(2);
|
|
1160
|
+
expect(stderr).toMatch(/fail-closed/i);
|
|
1161
|
+
expect(stderr).toContain('evaluated by the PATH CLI at');
|
|
1162
|
+
// The rendering is NON-resolvable: the PATH entry's basename plus the fixed
|
|
1163
|
+
// package suffix. Hook stderr lands in transcripts that get pasted into
|
|
1164
|
+
// issues, so a user-profile path must never ride along.
|
|
1165
|
+
expect(stderr).toContain('evaluated by the PATH CLI at global-bin/node_modules/@mmnto/cli/dist/index.js');
|
|
1166
|
+
expect(stderr).not.toContain(entry);
|
|
1167
|
+
expect(stderr).not.toContain(cwd);
|
|
1168
|
+
});
|
|
1169
|
+
it('no CLI anywhere → exit 2 naming exits this gate does NOT block', () => {
|
|
1170
|
+
const { status, stderr } = runWithPath(BOOTSTRAP, '');
|
|
1171
|
+
expect(status).toBe(2);
|
|
1172
|
+
expect(stderr).toMatch(/failing closed/i);
|
|
1173
|
+
expect(stderr).toContain('terminal OUTSIDE the harness');
|
|
1174
|
+
expect(stderr).toContain('.claude/settings.json');
|
|
1175
|
+
// The cure must not be a command this very gate blocks (the pre-fix message
|
|
1176
|
+
// said "Reinstall totem or run `totem eject`" — both Bash).
|
|
1177
|
+
expect(stderr).not.toContain('totem eject');
|
|
1178
|
+
});
|
|
1179
|
+
it('the repo-local CLI still wins when both resolve (pinned beats ambient)', () => {
|
|
1180
|
+
// ADR-072 §2 / Tenet 14: the PATH arm is a FALLBACK, never a preference.
|
|
1181
|
+
// The PATH stub would fail closed if the cascade ever inverted.
|
|
1182
|
+
writeCliUnder(cwd, { verdict: ALLOW, exit: 0 });
|
|
1183
|
+
writeCliUnder(pathDir, { exit: 1 });
|
|
1184
|
+
const { status, stderr } = runWithPath(BOOTSTRAP, pathDir);
|
|
1185
|
+
expect(status).toBe(0);
|
|
1186
|
+
expect(stderr).not.toContain('PATH CLI');
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
// ─── Install-time disclosure of the shell-matcher property (#2822) ─────
|
|
1190
|
+
describe('gate install discloses a Bash-matched gate applies to bootstrap', () => {
|
|
1191
|
+
let cwd;
|
|
1192
|
+
let originalCwd;
|
|
1193
|
+
let lines;
|
|
1194
|
+
let errorSpy;
|
|
1195
|
+
beforeEach(() => {
|
|
1196
|
+
cwd = makeTmpDir();
|
|
1197
|
+
originalCwd = process.cwd();
|
|
1198
|
+
process.chdir(cwd);
|
|
1199
|
+
lines = [];
|
|
1200
|
+
// `log.*` writes through console.error (ui.ts); capture the rendered lines.
|
|
1201
|
+
// Restored per-test rather than via restoreAllMocks, which would also clear
|
|
1202
|
+
// the module-level install-hooks seam this file mocks.
|
|
1203
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
1204
|
+
lines.push(args.map((a) => String(a)).join(' '));
|
|
1205
|
+
});
|
|
1206
|
+
});
|
|
1207
|
+
afterEach(() => {
|
|
1208
|
+
errorSpy.mockRestore();
|
|
1209
|
+
process.chdir(originalCwd);
|
|
1210
|
+
fs.rmSync(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
1211
|
+
});
|
|
1212
|
+
it('prints the line for a Bash|PowerShell gate and NOT for a Write|Edit gate', async () => {
|
|
1213
|
+
await gateInstallCommand({ name: 'transport-shield' });
|
|
1214
|
+
const shield = lines.join('\n');
|
|
1215
|
+
expect(shield).toContain('transport-shield matches Bash|PowerShell:');
|
|
1216
|
+
expect(shield).toContain("fresh clone's bootstrap commands (your package manager's install and build; here pnpm install and pnpm build)");
|
|
1217
|
+
expect(shield).toContain('bootstrap a fresh clone from a terminal outside the harness');
|
|
1218
|
+
lines = [];
|
|
1219
|
+
await gateInstallCommand({ name: 'freeze-check' });
|
|
1220
|
+
const freeze = lines.join('\n');
|
|
1221
|
+
// It DID install (the control), and said nothing about bootstrap: the
|
|
1222
|
+
// property is the shell matcher's, not every gate's.
|
|
1223
|
+
expect(freeze).toContain('freeze-check');
|
|
1224
|
+
expect(freeze).not.toContain('bootstrap');
|
|
1225
|
+
expect(freeze).not.toContain('matches Bash|PowerShell');
|
|
1226
|
+
// A third install in the SAME cwd: transport-shield is already present, so
|
|
1227
|
+
// the merge is a no-op — and the disclosure still prints beside it. The
|
|
1228
|
+
// property belongs to the installed gate, not to the write that installed
|
|
1229
|
+
// it, and a re-install is where an operator most often reads the output.
|
|
1230
|
+
lines = [];
|
|
1231
|
+
await gateInstallCommand({ name: 'transport-shield' });
|
|
1232
|
+
const again = lines.join('\n');
|
|
1233
|
+
expect(again).toContain('already present — no change');
|
|
1234
|
+
expect(again).toContain('transport-shield matches Bash|PowerShell:');
|
|
1235
|
+
expect(again).toContain('bootstrap a fresh clone from a terminal outside the harness');
|
|
1236
|
+
});
|
|
1237
|
+
// ─── Pilot-tier CLI floor (mmnto-ai/totem#2800 fold F3) ───────────────
|
|
1238
|
+
it('a --pilot install names the CLI floor its wrapper needs; a strict one does not', async () => {
|
|
1239
|
+
await gateInstallCommand({ name: 'merge-ready', pilot: true });
|
|
1240
|
+
const pilot = lines.join('\n');
|
|
1241
|
+
expect(pilot).toContain('gate check --tier pilot');
|
|
1242
|
+
expect(pilot).toContain('2.3.0');
|
|
1243
|
+
lines = [];
|
|
1244
|
+
await gateInstallCommand({ name: 'freeze-check' });
|
|
1245
|
+
const strict = lines.join('\n');
|
|
1246
|
+
// A strict install forwards no tier, so it carries no floor to disclose.
|
|
1247
|
+
expect(strict).not.toContain('2.3.0');
|
|
1248
|
+
expect(strict).not.toContain('--tier');
|
|
1249
|
+
});
|
|
1250
|
+
it('init --gates= discloses the same pilot floor (it prints its own rows)', async () => {
|
|
1251
|
+
await initCommand({ bare: true, gates: 'merge-ready', pilot: true });
|
|
1252
|
+
const out = lines.join('\n');
|
|
1253
|
+
expect(out).toContain('gate check --tier pilot');
|
|
1254
|
+
expect(out).toContain('2.3.0');
|
|
1255
|
+
});
|
|
659
1256
|
});
|
|
660
1257
|
// ─── eject parity ──────────────────────────────────────────────────────
|
|
661
1258
|
describe('eject removes the gate entry (parity with install)', () => {
|
|
@@ -727,6 +1324,38 @@ describe('init --gates= routes through the shared installer', () => {
|
|
|
727
1324
|
expect(matchersFor(cwd, 'transport-shield')).toEqual(['Bash|PowerShell']);
|
|
728
1325
|
expect(gateCommandFor(cwd, 'transport-shield')).toBe('node .claude/hooks/gate-wrapper.cjs --event transport-shield --strict');
|
|
729
1326
|
});
|
|
1327
|
+
it('init --gates= discloses the bootstrap property for a Bash-matched gate, not for Write|Edit', async () => {
|
|
1328
|
+
// The verb and init share `installGates` but NOT their output: init prints
|
|
1329
|
+
// its own summary rows, so before mmnto-ai/totem#2822's shared helper the
|
|
1330
|
+
// disclosure existed only on the verb and `--gates=` dropped it silently.
|
|
1331
|
+
const shieldLines = [];
|
|
1332
|
+
const shieldSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
1333
|
+
shieldLines.push(args.map((a) => String(a)).join(' '));
|
|
1334
|
+
});
|
|
1335
|
+
await initCommand({ bare: true, gates: 'transport-shield' });
|
|
1336
|
+
shieldSpy.mockRestore();
|
|
1337
|
+
const shield = shieldLines.join('\n');
|
|
1338
|
+
expect(shield).toContain('transport-shield matches Bash|PowerShell:');
|
|
1339
|
+
expect(shield).toContain('bootstrap a fresh clone from a terminal outside the harness');
|
|
1340
|
+
const freezeLines = [];
|
|
1341
|
+
const freezeSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
1342
|
+
freezeLines.push(args.map((a) => String(a)).join(' '));
|
|
1343
|
+
});
|
|
1344
|
+
await initCommand({ bare: true, gates: 'freeze-check' });
|
|
1345
|
+
freezeSpy.mockRestore();
|
|
1346
|
+
const freeze = freezeLines.join('\n');
|
|
1347
|
+
// The control: freeze-check installs (a row names it) and says nothing
|
|
1348
|
+
// about bootstrap — the property is the shell matcher's, not every gate's.
|
|
1349
|
+
expect(freeze).toContain('freeze-check');
|
|
1350
|
+
expect(freeze).not.toContain('bootstrap');
|
|
1351
|
+
expect(freeze).not.toContain('matches Bash|PowerShell');
|
|
1352
|
+
});
|
|
1353
|
+
it('init --gates=merge-ready installs it under Bash|PowerShell (mmnto-ai/totem#2800)', async () => {
|
|
1354
|
+
await initCommand({ bare: true, gates: 'merge-ready' });
|
|
1355
|
+
expect(gateEntryCount(cwd, 'merge-ready')).toBe(1);
|
|
1356
|
+
expect(matchersFor(cwd, 'merge-ready')).toEqual(['Bash|PowerShell']);
|
|
1357
|
+
expect(gateCommandFor(cwd, 'merge-ready')).toBe('node .claude/hooks/gate-wrapper.cjs --event merge-ready --strict');
|
|
1358
|
+
});
|
|
730
1359
|
it('init --gates= with an unknown member fails loud', async () => {
|
|
731
1360
|
await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
|
|
732
1361
|
});
|