@mmnto/cli 2.2.1 → 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 +17 -0
- package/dist/commands/gate-install.d.ts.map +1 -1
- package/dist/commands/gate-install.js +23 -0
- package/dist/commands/gate-install.js.map +1 -1
- package/dist/commands/gate-install.test.js +417 -0
- 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 +24 -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 +5 -5
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +730 -79
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +8 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +122 -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/sync-labels-forms.test.d.ts +7 -4
- package/dist/commands/sync-labels-forms.test.d.ts.map +1 -1
- package/dist/commands/sync-labels-forms.test.js +266 -18
- package/dist/commands/sync-labels-forms.test.js.map +1 -1
- 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
|
@@ -91,6 +91,7 @@ const TRANSPORT_SHIELD = {
|
|
|
91
91
|
event: 'transport-shield',
|
|
92
92
|
matcher: 'Bash|PowerShell',
|
|
93
93
|
};
|
|
94
|
+
const MERGE_READY = { event: 'merge-ready', matcher: 'Bash|PowerShell' };
|
|
94
95
|
/**
|
|
95
96
|
* Every PreToolUse matcher an entry installing `event` currently appears under.
|
|
96
97
|
* Matcher-AGNOSTIC by construction: a gate that leaked into a second matcher
|
|
@@ -237,6 +238,15 @@ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
|
|
|
237
238
|
},
|
|
238
239
|
],
|
|
239
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
|
+
};
|
|
240
250
|
beforeEach(() => {
|
|
241
251
|
cwd = makeTmpDir();
|
|
242
252
|
originalCwd = process.cwd();
|
|
@@ -286,6 +296,27 @@ describe('installGates per-gate matcher (mmnto-ai/totem#2799)', () => {
|
|
|
286
296
|
expect(cmd).toContain('--strict');
|
|
287
297
|
expect(cmd).not.toContain('--pilot');
|
|
288
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
|
+
});
|
|
289
320
|
it('after any sequence of INSTALLER writes, no gate appears under two matchers (hand edits are not enforced)', () => {
|
|
290
321
|
// Interleave tiers and selections — the upsert must converge on exactly one
|
|
291
322
|
// entry per gate, always under that gate's registry matcher.
|
|
@@ -427,8 +458,19 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
427
458
|
function writeStubCli(opts) {
|
|
428
459
|
const distDir = path.join(cwd, 'node_modules', '@mmnto', 'cli', 'dist');
|
|
429
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 });
|
|
430
468
|
const verdictJson = opts.verdict === undefined ? '' : JSON.stringify(opts.verdict);
|
|
431
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 ?? '';
|
|
432
474
|
// CommonJS stub (the wrapper invokes via `node <path>`); .js is fine here
|
|
433
475
|
// because there is no package.json type:module in the temp dir. The record
|
|
434
476
|
// is what lets a test assert BOTH that a spawn happened and exactly which
|
|
@@ -442,6 +484,8 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
442
484
|
'let stdin = "";',
|
|
443
485
|
'try { stdin = fs.readFileSync(0, "utf-8"); } catch { stdin = ""; }',
|
|
444
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");',
|
|
445
489
|
'if (out) process.stdout.write(out + "\\n");',
|
|
446
490
|
`process.exit(${exitCode});`,
|
|
447
491
|
'',
|
|
@@ -626,6 +670,11 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
626
670
|
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
627
671
|
const { status } = runWrapper(BASH_CMD, [], 'transport-shield');
|
|
628
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.
|
|
629
678
|
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'transport-shield', '--payload', '-']);
|
|
630
679
|
expect(spawnedPayload()).toEqual({
|
|
631
680
|
tool: 'Bash',
|
|
@@ -672,6 +721,51 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
672
721
|
expect(stubArgv()).toEqual(['gate', 'check', '--event', 'freeze-check', '--payload', '-']);
|
|
673
722
|
expect(spawnedPayload()).toEqual({ subsystem: 'rule-compilation' });
|
|
674
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
|
+
});
|
|
675
769
|
it('an --event the wrapper cannot project → exit 2 fail-closed, never spawns', () => {
|
|
676
770
|
writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
|
|
677
771
|
const { status, stderr } = runWrapper(BASH_CMD, [], 'nope');
|
|
@@ -685,6 +779,304 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
|
|
|
685
779
|
expect(status).toBe(2);
|
|
686
780
|
expect(stubArgv()).toBeNull();
|
|
687
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
|
+
});
|
|
688
1080
|
});
|
|
689
1081
|
// ─── The PATH fallback arm (mmnto-ai/totem#2822) ───────────────────────
|
|
690
1082
|
//
|
|
@@ -842,6 +1234,25 @@ describe('gate install discloses a Bash-matched gate applies to bootstrap', () =
|
|
|
842
1234
|
expect(again).toContain('transport-shield matches Bash|PowerShell:');
|
|
843
1235
|
expect(again).toContain('bootstrap a fresh clone from a terminal outside the harness');
|
|
844
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
|
+
});
|
|
845
1256
|
});
|
|
846
1257
|
// ─── eject parity ──────────────────────────────────────────────────────
|
|
847
1258
|
describe('eject removes the gate entry (parity with install)', () => {
|
|
@@ -939,6 +1350,12 @@ describe('init --gates= routes through the shared installer', () => {
|
|
|
939
1350
|
expect(freeze).not.toContain('bootstrap');
|
|
940
1351
|
expect(freeze).not.toContain('matches Bash|PowerShell');
|
|
941
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
|
+
});
|
|
942
1359
|
it('init --gates= with an unknown member fails loud', async () => {
|
|
943
1360
|
await expect(initCommand({ bare: true, gates: 'made-up-gate' })).rejects.toThrow(/unknown gate/i);
|
|
944
1361
|
});
|