@mmnto/cli 2.6.0 → 2.8.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.
@@ -1,8 +1,10 @@
1
- import { spawnSync } from 'node:child_process';
1
+ import { spawn, spawnSync } from 'node:child_process';
2
2
  import * as fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
3
4
  import * as os from 'node:os';
4
5
  import * as path from 'node:path';
5
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
6
8
  import { knownGates, TotemError } from '@mmnto/totem';
7
9
  import { ejectCommand } from './eject.js';
8
10
  import { gateInstallCommand } from './gate.js';
@@ -71,6 +73,686 @@ function envWithPath(value) {
71
73
  env.PATH = value;
72
74
  return env;
73
75
  }
76
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
77
+ /** Core's scanner of record for the parity lock (spec 2857 § 3). */
78
+ const CORE_TRANSPORT_SHIELD_SRC = path.resolve(HERE, '../../../core/src/transport-shield.ts');
79
+ /**
80
+ * Core's `findHeredocs`, loaded from its SOURCE at run time. The specifier is
81
+ * built at run time ON PURPOSE: the function is not re-exported from core's
82
+ * index and core's exports map has no subpath for it (mmnto-ai/totem#2851),
83
+ * while a STATIC relative import of another package's source fails
84
+ * `tsc --build` with TS6059 (`rootDir`). `bot-identity-parity.test.ts`, the
85
+ * exemplar, reads its sources as text for the same reason.
86
+ */
87
+ async function loadCoreFindHeredocs() {
88
+ const mod = (await import(pathToFileURL(CORE_TRANSPORT_SHIELD_SRC).href));
89
+ return mod.findHeredocs;
90
+ }
91
+ const requireCjs = createRequire(import.meta.url);
92
+ let wrapperExportsCache = null;
93
+ /**
94
+ * The rendered template's exports, loaded once from a temp `.cjs` (the file is
95
+ * removed again as soon as `require` has read it — nothing lands under the
96
+ * repo). Loading the RENDERED text, not the source, is what makes these rows
97
+ * read the same bytes the installed hook runs.
98
+ */
99
+ function wrapperExports() {
100
+ if (wrapperExportsCache === null) {
101
+ const dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'totem-gate-seam-')));
102
+ const file = path.join(dir, 'gate-wrapper.cjs');
103
+ fs.writeFileSync(file, CLAUDE_GATE_WRAPPER);
104
+ try {
105
+ wrapperExportsCache = requireCjs(file);
106
+ }
107
+ finally {
108
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
109
+ }
110
+ }
111
+ return wrapperExportsCache;
112
+ }
113
+ // ─── The command corpus (spec `.totem/specs/2857.md` § 3) ──────────────
114
+ //
115
+ // Every command string the merge-ready rows below assert on lives here ONCE,
116
+ // so the scanner-parity lock at the foot of this file can walk the same
117
+ // strings through BOTH scanners: a shape worth a projection row is a shape the
118
+ // ported scanner is held equal to core's on. A new heredoc-, comment- or
119
+ // quote-bearing row belongs in one of these lists, not inline in an `it`.
120
+ /** Fires at command position: after a separator, a reserved word, an assignment prefix. */
121
+ const COMMAND_POSITION_ROWS = [
122
+ 'git status && gh pr merge 7',
123
+ 'git fetch; gh pr merge 7',
124
+ 'for x in 1; do gh pr merge 7; done',
125
+ 'if true; then gh pr merge 7; fi',
126
+ 'git log |\ngh pr merge 7',
127
+ // PR round 1 (greptile): a merge used AS the condition, and one behind an
128
+ // assignment prefix, each left something other than `gh` at the segment's
129
+ // front and went unjudged.
130
+ 'if gh pr merge 7; then echo merged; fi',
131
+ 'if false; then :; elif gh pr merge 7; then :; fi',
132
+ 'while gh pr merge 7; do break; done',
133
+ 'until gh pr merge 7; do sleep 1; done',
134
+ 'GH_TOKEN=x gh pr merge 7',
135
+ 'GH_REPO=mmnto-ai/totem GH_TOKEN="a b" gh pr merge 7',
136
+ 'exec gh pr merge 7',
137
+ 'command gh pr merge 7',
138
+ // Round 2 (the leg's F1): two more reserved words and the builtin that runs
139
+ // an unquoted operand as the command.
140
+ 'time gh pr merge 7',
141
+ 'coproc gh pr merge 7',
142
+ 'eval gh pr merge 7',
143
+ ];
144
+ /** Must NEVER project — the false-deny direction this projection must not have. */
145
+ const NEVER_SPAWNS_ROWS = [
146
+ 'echo "gh pr merge 5"',
147
+ "echo 'gh pr merge 5'",
148
+ 'gh pr list',
149
+ 'gh pr view 3 | grep merge',
150
+ 'git commit -m "gh pr merge"',
151
+ // A heredoc body is DATA, not commands (fold F4): firing here was a false
152
+ // deny — the direction this projection must not have.
153
+ 'cat <<EOF\ngh pr merge 5\nEOF',
154
+ "cat <<'EOF'\ngh pr merge 5\nEOF",
155
+ 'cat <<-EOF\n\tgh pr merge 5\n\tEOF',
156
+ 'cat <<EOF > notes.txt\ngh pr merge 5\nEOF\necho done',
157
+ // An UNTERMINATED body runs to the end of the command and is still data.
158
+ 'cat <<EOF\ngh pr merge 5',
159
+ // A substitution inside SINGLE quotes really is data: bash does not expand
160
+ // it, so `echo '`gh pr merge 5`'` prints the text and merges nothing. The
161
+ // DOUBLE-quoted spellings are a different matter — bash runs those, and they
162
+ // are a disclosed MISS in MUTANT_ROWS below, not a control here (round-5
163
+ // leg, F4).
164
+ "echo '`gh pr merge 5`'",
165
+ "echo '$(gh pr merge 5)'",
166
+ ];
167
+ /** § A — the executable spellings that project. */
168
+ const EXECUTABLE_SPELLING_ROWS = [
169
+ 'gh.exe pr merge 5',
170
+ 'gh.EXE pr merge 5',
171
+ // win32 resolves file names without case, so the WHOLE `gh.exe` basename
172
+ // compares case-insensitively — the stem-only lower-casing left these two
173
+ // unjudged (CodeRabbit on mmnto-ai/totem#2894). The bare `gh` stays exact.
174
+ 'GH.EXE pr merge 5',
175
+ 'Gh.exe pr merge 5',
176
+ './gh pr merge 5',
177
+ '/usr/local/bin/gh pr merge 5',
178
+ // A win32 path reaches the executable test only when it is QUOTED: this walk
179
+ // reads POSIX quoting for BOTH tools (disclosed since the first round), so an
180
+ // unquoted `C:\tools\gh.exe` arrives as `C:toolsgh.exe` with its separators
181
+ // consumed as escapes — locked as a miss in MUTANT_ROWS.
182
+ "'C:\\tools\\gh.exe' pr merge 5",
183
+ '"/opt/hub/gh" pr merge 5',
184
+ ];
185
+ /** § B — the transparent wrapper programs and flag-carrying builtins that project. */
186
+ const WRAPPER_STRIP_ROWS = [
187
+ 'sudo gh pr merge 5',
188
+ 'sudo -u root gh pr merge 5',
189
+ 'sudo --user=root gh pr merge 5',
190
+ 'sudo -- gh pr merge 5',
191
+ // A long option that takes a SEPARATE operand needs its own table entry, or
192
+ // the operand reads as the program and the merge behind it goes unjudged
193
+ // (round-5 leg, F2 — an undisclosed miss family inside the closed table).
194
+ 'sudo --user root gh pr merge 5',
195
+ 'sudo --group grp gh pr merge 5',
196
+ 'sudo --prompt p gh pr merge 5',
197
+ // The SHORT separate-operand spellings the table omitted (Greptile P1 on
198
+ // mmnto-ai/totem#2894 named `-R`; `-a` and `-c` are the same class, per
199
+ // sudo(8)): the generic path dropped the option alone and left its operand
200
+ // standing at command position, so each of these ran unjudged.
201
+ 'sudo -R /chroot gh pr merge 5',
202
+ 'sudo --chroot /chroot gh pr merge 5',
203
+ 'sudo -a bsdauth gh pr merge 5',
204
+ 'sudo -c staff gh pr merge 5',
205
+ // sudo options that are NOT describe-only: `-E` keeps the environment, `-b`
206
+ // runs the command in the background. Both still execute the operand.
207
+ 'sudo -E gh pr merge 5',
208
+ 'sudo -b gh pr merge 5',
209
+ 'env GH_TOKEN=x gh pr merge 5',
210
+ 'env -u X A=1 gh pr merge 5',
211
+ 'env --unset X gh pr merge 5',
212
+ 'env --chdir /tmp gh pr merge 5',
213
+ // `-S`/`--split-string` hands env the COMMAND as its operand: env splits that
214
+ // string into words, PREPENDS them to what follows and runs the first word
215
+ // (measured, coreutils 8.32, with a stub `gh`). Consuming the operand with
216
+ // the option made every spelling of it a miss — a bypass under the strict
217
+ // tier — so the words take the option's place now (fold 3).
218
+ "env -S 'gh pr merge 5'",
219
+ "env --split-string='gh pr merge 5'",
220
+ "env -S 'A=1 gh pr merge 5'",
221
+ // The ATTACHED SHORT spellings (round-7 leg, H5). A short option carries its
222
+ // operand with no separator at all, and the quote arm joins `-S'…'` into
223
+ // that same token, so both arrive as `-Sgh pr merge 5`. coreutils runs the
224
+ // merge in each (measured, 8.32: `GH-RAN argc=3 argv=[pr merge 5]`) and
225
+ // fold 3 left them a miss — one more bypass under the strict tier.
226
+ 'env -Sgh pr merge 5',
227
+ "env -S'gh pr merge 5'",
228
+ // An `=` after a SHORT option's letter is not a separator and not part of an
229
+ // operand this walk reads: the whole token is dropped as a flag of env, and
230
+ // that is the route that reads this spelling right (round-8 leg, J2). env
231
+ // splits `=X` into one word, an assignment with an EMPTY NAME, so the
232
+ // command is `gh pr merge 5` and the merge RUNS (measured, coreutils 8.32,
233
+ // with a recording stub: `GH-RAN [pr] [merge] [5]`). On the fold-4 hook the
234
+ // attached-operand arm read `=X` as the operand, put it at the front of the
235
+ // strip — neither an assignment this walk accepts nor a command — and
236
+ // projected NOTHING: a fail-open, which is a bypass under the strict tier.
237
+ 'env -S=X gh pr merge 5',
238
+ 'timeout 30 gh pr merge 5',
239
+ 'timeout 30s gh pr merge 5',
240
+ 'timeout -k 5 30 gh pr merge 5',
241
+ 'timeout --kill-after=5 30 gh pr merge 5',
242
+ 'timeout --kill-after 5 30 gh pr merge 5',
243
+ 'timeout --signal KILL 30 gh pr merge 5',
244
+ 'timeout --foreground 30 gh pr merge 5',
245
+ 'nice -n 10 gh pr merge 5',
246
+ 'nice --adjustment=10 gh pr merge 5',
247
+ 'nice --adjustment 10 gh pr merge 5',
248
+ 'nice -10 gh pr merge 5',
249
+ 'nohup gh pr merge 5',
250
+ 'command -p gh pr merge 5',
251
+ 'exec -a x gh pr merge 5',
252
+ 'time -p gh pr merge 5',
253
+ 'time -- gh pr merge 5',
254
+ // A wrapper wrapping a wrapper: the strip loops until the head is the
255
+ // command itself.
256
+ 'sudo -u root timeout 30 gh pr merge 5',
257
+ 'nohup nice -n 5 gh pr merge 5',
258
+ // `eval` re-tokenizes its operand ONCE (depth 1) and projects from the inner
259
+ // string — a merge handed over as one quoted word.
260
+ 'eval "gh pr merge 5"',
261
+ "eval 'gh pr merge 5'",
262
+ ];
263
+ /** § C — leading redirections and backtick substitutions that project. */
264
+ const REDIRECTION_ROWS = [
265
+ '> out.txt gh pr merge 5',
266
+ '>out.txt gh pr merge 5',
267
+ '>> log.txt gh pr merge 5',
268
+ '< in.txt gh pr merge 5',
269
+ '2> err.txt gh pr merge 5',
270
+ '2>/dev/null gh pr merge 5',
271
+ // `&>` is not read as one operator — `&` ends the segment — but the segment
272
+ // AFTER it starts at the `>`, which the arms do read.
273
+ '&> out.txt gh pr merge 5',
274
+ // A redirection in front of a wrapper program: both strips run.
275
+ '> out.txt sudo gh pr merge 5',
276
+ // The strip runs over the WHOLE segment, not just its front (round-5 leg,
277
+ // F5 + F12): a redirection BETWEEN the executable and its verb no longer
278
+ // breaks the anchor, and the here-string and `<>` spellings are operators
279
+ // too.
280
+ 'gh > out.txt pr merge 5',
281
+ '<<<bar gh pr merge 5',
282
+ '<<< bar gh pr merge 5',
283
+ '2<> file gh pr merge 5',
284
+ 'echo `gh pr merge 5`',
285
+ '`gh pr merge 5`',
286
+ // A QUOTED FILENAME does not make the redirection data (round-7 leg, H1):
287
+ // bash decides on the OPERATOR's quoting, and in every one of these the
288
+ // operator characters are bare, so the shell truncates the file and runs the
289
+ // merge. Under the round-6 rule ("any part of the token came from quotes")
290
+ // the whole word was read as data and each of these projected NOTHING —
291
+ // the regression that rule introduced against main.
292
+ '>"out.txt" gh pr merge 5',
293
+ '2>"err.log" gh pr merge 5',
294
+ '2>"/dev/null" gh pr merge 5',
295
+ '<"in.txt" gh pr merge 5',
296
+ "<<<'bar' gh pr merge 5",
297
+ // …and the FILENAME may carry whitespace (round-8 leg, J1). A quoted name
298
+ // with a space in it is ONE token here, so while the fused pattern's
299
+ // filename class excluded whitespace the token matched neither pattern: it
300
+ // stood in front of `gh`, broke the anchor, and each of these projected
301
+ // NOTHING on the fold-4 hook — while bash truncates `out file.txt` (feeds
302
+ // the here-string `bar baz`) and merges PR 5, measured with a recording stub
303
+ // on bash 5.3.
304
+ '>"out file.txt" gh pr merge 5',
305
+ '<<<"bar baz" gh pr merge 5',
306
+ // …and the operator prefix is BOUNDED by the first literal index (fold 6).
307
+ // Bash extends an operator over UNQUOTED characters only, so each of these
308
+ // is the BARE leading operator with the quoted rest as its filename — a real
309
+ // redirection with a real merge behind it. On the fold-5 hook the greedy
310
+ // `[<>]{1,2}` read `>>` / `<<<`, a prefix ending PAST that index, so the
311
+ // guard kept the word: it stood in front of `gh`, broke the anchor and each
312
+ // of these projected NOTHING — a bypass under STRICT. Measured on bash 5.3
313
+ // with a recording stub, each case in its OWN empty directory: gh's argv is
314
+ // `[pr] [merge] [5]` in all five, the file `>out.txt` (row 4: `>a b`) is
315
+ // created, and row 5 merges behind a "here-document delimited by
316
+ // end-of-file" warning.
317
+ '>">"out.txt gh pr merge 5',
318
+ ">'>'out.txt gh pr merge 5",
319
+ '>\\>out.txt gh pr merge 5',
320
+ '>">a "b gh pr merge 5',
321
+ '<<"<"bar gh pr merge 5',
322
+ // The `2<` arm of the same rule, whose bash truth depends on the FILE. With
323
+ // `>out.txt` present bash reads the operator `2<` and the filename
324
+ // `>out.txt`, and the merge runs (measured: `[pr] [merge] [5]`, exit 0) —
325
+ // the fold-5 hook projected nothing for it. With the file absent the
326
+ // redirection fails ("No such file or directory", exit 1, gh never runs)
327
+ // and this projection is a disclosed false fire in the DENY direction.
328
+ // Stripping is right either way: not stripping is a bypass whenever the
329
+ // file exists.
330
+ '2<">"out.txt gh pr merge 5',
331
+ ];
332
+ /**
333
+ * § F — one mutant per widened form, and the LOCKED disclosed misses; none of
334
+ * them may project. A mutant is a shape the shell does not run as a merge; a
335
+ * disclosed miss is one it DOES run and this walk cannot decide — each is
336
+ * named as which in its comment, so the residue in the template's comment is
337
+ * read from these rows rather than from memory.
338
+ */
339
+ const MUTANT_ROWS = [
340
+ // § B: the operand of `sudo -u` IS `gh`, so the command is `pr`.
341
+ 'sudo -u gh pr merge 5',
342
+ // sudo's DESCRIBE-only options run nothing at all: `-l`/`--list` prints the
343
+ // policy, `-v`/`--validate` refreshes the timestamp, `-V`/`--version` prints
344
+ // a version, `-K`/`--remove-timestamp` clears credentials and may not carry
345
+ // a command. Projecting a merge there was a FALSE DENY on a command the
346
+ // shell never runs (round-5 leg, F1).
347
+ 'sudo -l gh pr merge 5',
348
+ 'sudo --list gh pr merge 5',
349
+ 'sudo -v gh pr merge 5',
350
+ 'sudo --validate gh pr merge 5',
351
+ 'sudo -V gh pr merge 5',
352
+ 'sudo --version gh pr merge 5',
353
+ 'sudo -K gh pr merge 5',
354
+ 'sudo --remove-timestamp gh pr merge 5',
355
+ // `time` is bash's RESERVED WORD (`time [-p] [--] pipeline`), not
356
+ // `/usr/bin/time`: no option of it takes an operand, and any other `-` token
357
+ // is a command bash cannot find — nothing runs, so nothing is projected
358
+ // (round-5 leg, F3). Both spellings were read with GNU time's grammar.
359
+ 'time -f x gh pr merge 5',
360
+ 'time -o out.txt gh pr merge 5',
361
+ // LOCKED: the PROGRAM spelled by path is not the reserved word, and a
362
+ // path-spelled wrapper is not on the closed table at all.
363
+ '/usr/bin/time -f x gh pr merge 5',
364
+ // LOCKED, and the BOUNDARY of the `-S` split rather than a mutant (fold 3):
365
+ // the operand IS read now, but on env's WHITESPACE rule alone. `\_` is env's
366
+ // own escape for a space, so `env -S 'gh\_pr\_merge\_5'` really runs
367
+ // `gh pr merge 5` (measured, coreutils 8.32, stub `gh`) while this walk
368
+ // reads ONE word and projects nothing — a fail-open, disclosed in the
369
+ // template's comment and read from here. The NESTED spelling is the other
370
+ // half of the same boundary (round-7 leg, H5): env does not leave the
371
+ // quotes inside its operand alone, so `env -S 'env -S "gh pr merge 5"'`
372
+ // runs the merge (measured) while this walk splits on whitespace, reads
373
+ // `"gh` as the executable and projects nothing.
374
+ "env -S 'gh\\_pr\\_merge\\_5'",
375
+ 'env -S \'env -S "gh pr merge 5"\'',
376
+ // NOT a miss: an `=` is not a separator for a SHORT option, so env reads the
377
+ // operand `=gh pr merge 5`, takes `=gh` as an assignment with an empty name
378
+ // and runs `pr merge 5` — coreutils `pr`, which answers
379
+ // `pr: merge: No such file or directory` (measured, coreutils 8.32: the case
380
+ // exits 1 and the stub `gh` is never reached). No merge runs and none is
381
+ // projected. The ROUTE is the one J2 rules (round-8 leg): the token is
382
+ // dropped as a flag of env, where the fold-4 hook reached the same silence
383
+ // by splitting `=gh pr merge 5` into words and stalling on `=gh` — the same
384
+ // split that made `env -S=X gh pr merge 5` above a MISS.
385
+ "env -S='gh pr merge 5'",
386
+ // LOCKED DISCLOSED MISSES, not mutants (round-8 leg, J4): CLUSTERED short
387
+ // options. The option test reads the WHOLE `-` token, so `-vu` and `-iS`
388
+ // match no entry of env's operand list, are dropped as one flag each, and
389
+ // the operand belonging to the cluster's LAST letter (`X` for `-vu`, the
390
+ // command string for `-iS`) is left at the front of the strip, where it
391
+ // blocks the anchor. coreutils RUNS the merge in both (measured, 8.32, with
392
+ // a recording stub: `env -vu X gh pr merge 5` records `GH-RAN [pr] [merge]
393
+ // [5]`, and the `-iS` spelling does too when the operand names the stub and
394
+ // its record file by absolute path, since `-i` clears the environment).
395
+ // ATTACHMENT is not the gap — `env -uX`, `nice -n10`, `timeout -k5 30` and
396
+ // `timeout -sTERM 30` all project and all run — CLUSTERING is.
397
+ 'env -vu X gh pr merge 5',
398
+ "env -iS 'gh pr merge 5'",
399
+ // `timeout` with no duration: the grammar consumes exactly one positional
400
+ // before the command, so `gh` reads as the duration. A disclosed
401
+ // false-negative of the grammar, locked here (the form is invalid to
402
+ // `timeout` itself).
403
+ 'timeout gh pr merge 5',
404
+ // `command -v` / `-V` DESCRIBE their operand, they never execute it.
405
+ 'command -v gh pr merge 5',
406
+ 'command -V gh pr merge 5',
407
+ // Not on the closed list: `npx` runs a package, never the GitHub CLI.
408
+ 'npx gh pr merge 5',
409
+ 'xargs gh pr merge 5',
410
+ 'bash -c "gh pr merge 5"',
411
+ // `eval` is bounded to ONE level.
412
+ 'eval "eval \\"gh pr merge 5\\""',
413
+ // § C, LOCKED DISCLOSED MISSES — not mutants, all five of them (round-6
414
+ // leg, G2). Measured with a stub `gh` on bash 5.3: `>| out.txt`, `2>&1`,
415
+ // `>& file`, `<& 3` (once that descriptor is open) and `exec 3>&1` each
416
+ // apply their redirection and then RUN `gh pr merge 5`. The wrapper misses
417
+ // every one for the same reason: `|` and `&` are its own segment
418
+ // separators, so the operator never arrives as one token and the segment
419
+ // they leave starts at the FILE (`out.txt`, `1`, `file`, `3`), not at a
420
+ // redirection. They sit here because nothing projects, which is what this
421
+ // loop asserts — but the reason is a fail-open, not text the shell ignores.
422
+ '>| out.txt gh pr merge 5',
423
+ '2>&1 gh pr merge 5',
424
+ '>& file gh pr merge 5',
425
+ '<& 3 gh pr merge 5',
426
+ 'exec 3>&1 gh pr merge 5',
427
+ // § A: a near-miss executable. `gh.cmd` is a DIFFERENT program (and not
428
+ // resolvable as `gh` by spawn without a shell); `$GH` is a variable this
429
+ // wrapper cannot expand.
430
+ 'ghx pr merge 5',
431
+ 'gh.cmd pr merge 5',
432
+ '$GH pr merge 5',
433
+ '${GH} pr merge 5',
434
+ // The unquoted win32 path (see EXECUTABLE_SPELLING_ROWS): its backslashes
435
+ // are consumed as escapes before the executable test sees the token.
436
+ 'C:\\tools\\gh.exe pr merge 5',
437
+ // A DISCLOSED MISS, not a control (round-5 leg, F4): bash EXECUTES a
438
+ // backtick pair and a `$( … )` inside double quotes, so both of these merge
439
+ // PR 5 — and this walk's quote arms swallow them as one token, so neither is
440
+ // judged. A fail-open, filed as mmnto-ai/totem#2893; they sit here because
441
+ // the observable is the same (nothing projects), but the reason is the
442
+ // opposite of the single-quoted control above.
443
+ 'echo "`gh pr merge 5`"',
444
+ 'echo "$(gh pr merge 5)"',
445
+ ];
446
+ /**
447
+ * An INVALID option to a table word is a disclosed FALSE FIRE (round-6 leg,
448
+ * G4). Measured: `command -x`, `exec -x`, `timeout -Z 30`, `nice -Z`,
449
+ * `env -Z`, `nohup -x` and `sudo -Z` each make the program print an
450
+ * invalid-option error and run NOTHING, while the strip reads the unknown `-`
451
+ * token as one of the word's own options and projects PR 5. Ruled: disclose
452
+ * it, do not cure it. The cure would be a closed `flags` list per program —
453
+ * the shape `time` has, whose reserved-word grammar really is two flags — and
454
+ * measured on a mutant with that list everywhere, it turns `sudo -n`,
455
+ * `sudo -E` and `timeout --foreground` (REAL flags, real merges) into misses
456
+ * as well. A miss is a bypass under the strict tier; a false fire on a
457
+ * command that runs nothing costs one bogus deny, which the override clears.
458
+ * These rows assert what the wrapper DOES, so the template's false-fires
459
+ * paragraph is read from rows rather than from memory.
460
+ */
461
+ const INVALID_OPTION_FALSE_FIRE_ROWS = [
462
+ 'command -x gh pr merge 5',
463
+ 'exec -x gh pr merge 5',
464
+ 'timeout -Z 30 gh pr merge 5',
465
+ 'nice -Z gh pr merge 5',
466
+ 'env -Z gh pr merge 5',
467
+ 'nohup -x gh pr merge 5',
468
+ 'sudo -Z gh pr merge 5',
469
+ ];
470
+ /** An arithmetic shift or a comment must not swallow the merge that follows. */
471
+ const ARITHMETIC_COMMENT_ROWS = [
472
+ 'echo $((1<<2)); gh pr merge 5',
473
+ 'echo $(( 3<<1 )); gh pr merge 5',
474
+ '# see <<note\ngh pr merge 5',
475
+ '(( 1<<3 ))\ngh pr merge 5',
476
+ 'echo hi # <<EOF\ngh pr merge 5',
477
+ ];
478
+ /** A `<<<` here-string is not a heredoc: the merge after it still fires. */
479
+ const HERESTRING_ROWS = [
480
+ 'grep x <<< bar\ngh pr merge 5',
481
+ 'grep x <<<bar\ngh pr merge 5',
482
+ '<<<bar\ngh pr merge 5',
483
+ ];
484
+ /** A backslash-newline joins two halves of one word. */
485
+ const LINE_CONTINUATION_ROWS = ['gh \\\npr merge 5', 'gh pr merge \\\n5', 'gh \\\r\npr merge 5'];
486
+ /**
487
+ * PowerShell's line continuation is a trailing BACKTICK — the twin of bash's
488
+ * trailing backslash AT A WORD BOUNDARY (round-6 leg, G3; bounded by round-7's
489
+ * H4). In ps mode the backtick and the newline after it are consumed there and
490
+ * the next line continues the command; in bash a backtick opens a substitution
491
+ * and stays a segment separator, so this arm is ps-only.
492
+ * Before it, `gh pr merge <backtick><LF>5` projected `unresolvedTarget: '`'`
493
+ * and PR 5 was merged on the next segment unjudged — strict denied a target
494
+ * nobody wrote, pilot warned and let the merge through.
495
+ */
496
+ const PS_LINE_CONTINUATION_ROWS = [
497
+ 'gh pr merge `\n5',
498
+ 'gh pr merge 5 `\n--admin',
499
+ 'gh pr merge `\r\n5',
500
+ ];
501
+ /**
502
+ * …but only AT A WORD BOUNDARY (round-7 leg, H4). PowerShell's backtick is its
503
+ * ESCAPE character: inside a word it escapes the newline INTO the argument.
504
+ * Measured on pwsh 7.6.6 through a script that prints its arguments as bytes:
505
+ * `merg<backtick><LF>e 5` arrives as the two arguments `109,101,114,103,10,101`
506
+ * (`merg<LF>e`) and `53` (`5`), while the same continuation at a boundary
507
+ * arrives as `merge` and `5`. So `gh pr merg<backtick><LF>e 5` hands gh the
508
+ * verb `merg<LF>e`, which is not `merge` and merges nothing, and
509
+ * `g<backtick><LF>h pr merge 5` names a command `g<LF>h` that does not
510
+ * resolve. Bash's backslash-newline really joins its halves; this one does
511
+ * not, and reading it as a join projected a merge the shell never runs. Both
512
+ * of these projected `[['5']]` on the fold-3 hook.
513
+ */
514
+ const PS_CONTINUATION_INSIDE_WORD_ROWS = ['gh pr merg`\ne 5', 'g`\nh pr merge 5'];
515
+ /**
516
+ * A disclosed FALSE FIRE (round-7 leg, H4): a backtick that is the LAST
517
+ * CHARACTER of the input — nothing after it, not even a newline — is a
518
+ * continuation with nothing to continue, and pwsh answers with a parse error
519
+ * without running anything. Here the backtick is not followed by a newline, so
520
+ * it falls through to the segment-separator arm and the merge in front of it
521
+ * is judged — the deny direction, on text the shell rejects. Narrowed to that
522
+ * one spelling on a measurement (round-8 leg, J3): give the same input a
523
+ * trailing newline (`gh pr merge 5 <backtick><LF>`) and pwsh runs the merge
524
+ * (recorded argv `[pr] [merge] [5]`), while the continuation arm consumes the
525
+ * pair and projects PR 5 — the two agree, and only this spelling diverges.
526
+ */
527
+ const ROW_PS_TRAILING_BACKTICK = 'gh pr merge 5 `';
528
+ /**
529
+ * A disclosed FALSE FIRE of the `env -S` re-entry (round-7 leg, H5). env does
530
+ * its own expansion inside the operand and supports only `${VARNAME}`: given
531
+ * `$PR` it refuses the whole command — `env: only ${VARNAME} expansion is
532
+ * supported, error at: $PR` (measured, coreutils 8.32) — and runs nothing.
533
+ * This walk splits the operand on whitespace, reaches the anchor and reads
534
+ * `$PR` as a target it cannot expand, so the payload carries
535
+ * `unresolvedTarget` and the strict tier denies a merge that never happens.
536
+ * The deny direction, which is the safe one; a row, not a claim.
537
+ */
538
+ const ROW_ENV_S_UNEXPANDED = "env -S 'gh pr merge $PR'";
539
+ // Shapes each asserted by a row of their own below, named here so the parity
540
+ // corpus reads them too.
541
+ const ROW_TWO_HEREDOC_BODIES = 'cat <<A <<B\nfirst\nA\ngh pr merge 5\nB\n';
542
+ const ROW_HEREDOC_AS_OPERAND = 'gh pr merge 5 <<EOF\nnotes\nEOF\n';
543
+ const ROW_MERGE_AFTER_HEREDOC = 'cat <<EOF > body.md\nsome release notes\nEOF\ngh pr merge 21';
544
+ const ROW_PS_BLOCK_MULTILINE = '<#\ngh pr merge 9\n#>\necho hi';
545
+ const ROW_PS_BLOCK_INLINE = '<# gh pr merge 9 #>\necho hi';
546
+ const ROW_PS_BLOCK_THEN_MERGE = '<# notes #>\ngh pr merge 4';
547
+ const ROW_BASH_HASH_REDIRECT = 'sort <#tmp\ngh pr merge 8';
548
+ const ROW_SUBSTITUTION_COMMENT = 'echo $(# <<note\ngh pr merge 5\n)';
549
+ const ROW_HERESTRING_AS_OPERAND = 'gh pr merge 6 <<< notes';
550
+ const ROW_TRAILING_COMMENT = 'echo hi # gh pr merge 9';
551
+ /**
552
+ * The two comments that DISCRIMINATE the comment blanking (round-5 leg, F9).
553
+ * Each carries a tokenizer SEPARATOR inside the comment — a `;` and a backtick
554
+ * — so with the comment regions left in place the merge behind it reaches a
555
+ * segment's front and fires (verified on a rendered copy with the blanker's
556
+ * comment loop removed: pr 9 and pr 5). `ROW_TRAILING_COMMENT` above does NOT
557
+ * discriminate: with or without the blanking its merge stays inside the `echo`
558
+ * segment, so it proves nothing about the comment arms on its own.
559
+ */
560
+ const ROW_COMMENT_SEPARATOR = 'echo hi # ; gh pr merge 9';
561
+ const ROW_COMMENT_BACKTICK = 'echo hi # use `gh pr merge 5` to merge';
562
+ const ROW_PAREN_COMMENT_HEREDOC = '(true)#<<note\ngh pr merge 5';
563
+ const ROW_COLON_DELIMITER = 'cat <<E:F\nbody\nE:F\ngh pr merge 5';
564
+ const ROW_PS_CALL_OPERATOR = '& gh pr merge 5';
565
+ /**
566
+ * A TRAILING redirection (round-5 leg, F5): the shell writes gh's output to
567
+ * the file and merges the current branch's PR. Read at the segment's FRONT
568
+ * only, the `>` rode into argv as the merge's first positional and the payload
569
+ * named a branch `>` — the engine denied a pull request on a branch no one
570
+ * wrote, a deny with a false reason.
571
+ */
572
+ const ROW_TRAILING_REDIRECT_BRANCH = 'gh pr merge --squash > merge.log';
573
+ const ROW_TRAILING_REDIRECT_ERR = 'gh pr merge --squash 2> err.log';
574
+ const ROW_TRAILING_REDIRECT_PR = 'gh pr merge 5 > out.txt';
575
+ /**
576
+ * A redirection LOOKALIKE that came out of quotes or a backslash escape
577
+ * (round-6 leg, G1). The whole-segment strip above reads a token's TEXT, and
578
+ * `<br>` spelled as the body of `-b` has the text of a fused redirection — so
579
+ * stripping it dropped the merge's own argument, and with the value-flag
580
+ * pairing broken the token AFTER it was swallowed instead: `-b "<br>" 5` lost
581
+ * PR 5 to the current branch, and `-t ">>" --repo owner/name 5` lost the repo
582
+ * (a `>>` ALONE takes the next token with it, and that token was `--repo`).
583
+ * The shell quotes those for exactly this reason — they are data. The
584
+ * tokenizer now records per token whether any part of it came from inside
585
+ * quotes or from an escape, and the strip skips a literal token.
586
+ */
587
+ const ROW_LITERAL_BODY_PR = 'gh pr merge -b "<br>" 5';
588
+ const ROW_LITERAL_BODY_REPO = 'gh pr merge -b "<br>" --repo owner/name 5';
589
+ const ROW_LITERAL_SUBJECT_REPO = 'gh pr merge -t ">>" --repo owner/name 5';
590
+ const ROW_LITERAL_ESCAPED_BODY = 'gh pr merge -b \\<br\\> 5';
591
+ /**
592
+ * A TRAILING redirection whose FILENAME is quoted (round-7 leg, H1 BLOCKING /
593
+ * H2 MATERIAL, one root). The round-6 rule above marked a token literal when
594
+ * ANY part of it came from quotes or an escape; the shell's rule is OPERATOR
595
+ * quoting — `>"merge.log"` is as real a redirection as `> merge.log`, and only
596
+ * `">"merge.log`, with the operator itself quoted, is data. Under the round-6
597
+ * rule each of these rode into argv, so the branch rows reached the engine as
598
+ * a pull request on a branch named `>merge.log` / `2>err.log` / `>>out.txt` /
599
+ * `>$FILE` — a deny with a reason no one wrote, which is the exact shape the
600
+ * whole-segment strip was added to cure.
601
+ */
602
+ const ROW_QUOTED_REDIRECT_BRANCH = 'gh pr merge --squash >"merge.log"';
603
+ const ROW_QUOTED_REDIRECT_ERR = 'gh pr merge --squash 2>"err.log"';
604
+ const ROW_QUOTED_APPEND_BRANCH = 'gh pr merge --squash >>"out.txt"';
605
+ const ROW_QUOTED_VAR_BRANCH = 'gh pr merge --squash >"$FILE"';
606
+ const ROW_QUOTED_REDIRECT_PR = 'gh pr merge 5 >"out.txt"';
607
+ /**
608
+ * The other exemplar of the same rule, LOCKED: quote the OPERATOR and bash
609
+ * passes the word to gh as an ARGUMENT (`>out.txt`), redirecting nothing. The
610
+ * first literal character is at index 0, on the operator itself, so the prefix
611
+ * does not lie before it and the token is kept.
612
+ */
613
+ const ROW_QUOTED_OPERATOR_DATA = 'gh pr merge --squash ">"out.txt';
614
+ /**
615
+ * The TRAILING twins of the whitespace rule (round-8 leg, J1): the operator is
616
+ * bare, the filename is quoted and carries a space, so bash redirects and
617
+ * merges the current branch's PR — while the fold-4 hook, whose fused pattern
618
+ * excluded whitespace from the filename, kept the whole word and reached the
619
+ * engine with a branch named `>merge log.txt` / `<<<bar baz`. Measured on
620
+ * bash 5.3 with a recording stub: gh's argv is `[--squash]` in both.
621
+ */
622
+ const ROW_WS_REDIRECT_BRANCH = 'gh pr merge --squash >"merge log.txt"';
623
+ const ROW_WS_HERESTRING_BRANCH = 'gh pr merge --squash <<<"bar baz"';
624
+ /**
625
+ * The RESIDUE of that rule, locked rather than cured (round-8 leg, J5): an
626
+ * empty quote pair or a QUOTED operator abutting a real one. Bash reads the
627
+ * quoted part as an ARGUMENT and applies the redirection that follows it —
628
+ * measured with a recording stub, gh's argv is `[--squash] []` for the first
629
+ * and `[--squash] [>]` for the second — while this walk keeps each as ONE word
630
+ * and names it as the target. The divergence is in the argv's TEXT only: both
631
+ * of those words are targets the engine denies, so nothing gets through, and
632
+ * the rows assert the current projection rather than a claim in the comment.
633
+ */
634
+ const ROW_EMPTY_QUOTE_ABUT = 'gh pr merge --squash "">out.txt';
635
+ const ROW_QUOTED_OPERATOR_ABUT = 'gh pr merge --squash ">">out.txt';
636
+ /**
637
+ * The TRAILING twin of the bounded-prefix rule (fold 6): the operator is the
638
+ * BARE `>` and `">"merge.log` is its filename, so bash merges the current
639
+ * branch's PR and writes the file `>merge.log` — measured with a recording
640
+ * stub, gh's argv is `[--squash]`. On the fold-5 hook the greedy `>>` ended
641
+ * past the first literal index, the whole word rode into argv, and the engine
642
+ * was handed a pull request on a branch named `>>merge.log`.
643
+ */
644
+ const ROW_ABUT_REDIRECT_BRANCH = 'gh pr merge --squash >">"merge.log';
645
+ /**
646
+ * The keep-row of the same rule, LOCKED: a quoted argument whose text merely
647
+ * CONTAINS an operator, spaces and all. Its first literal character is at
648
+ * index 0, so no operator prefix lies before it and nothing is stripped —
649
+ * measured, gh's argv is `[-b] [a > b] [5]`.
650
+ */
651
+ const ROW_LITERAL_BODY_SPACED = 'gh pr merge -b "a > b" 5';
652
+ /**
653
+ * The residue the bounded prefix leaves, disclosed as a FALSE FIRE and locked
654
+ * both ways round (fold 6): an EMPTY quote pair INSIDE the operator prefix.
655
+ * The word here is `>>out.txt` with its first literal character at index 1, so
656
+ * the bounded prefix is `>` and the word strips — while bash reads `>` with an
657
+ * EMPTY filename and fails the redirection ("No such file or directory", exit
658
+ * 1, gh never runs, measured in an empty directory). So the wrapper judges a
659
+ * merge the shell never ran: noise in the deny direction, never a bypass. The
660
+ * fold-5 hook kept the word instead and projected `['--squash', '>>out.txt']`
661
+ * trailing / nothing at all leading.
662
+ */
663
+ const ROW_EMPTY_PAIR_IN_OPERATOR_LEAD = '>"">out.txt gh pr merge 5';
664
+ const ROW_EMPTY_PAIR_IN_OPERATOR = 'gh pr merge --squash >"">out.txt';
665
+ /**
666
+ * A disclosed FALSE FIRE, PowerShell's third (round-8 leg, J3): a backtick
667
+ * followed by WHITESPACE and then a newline is not a continuation — the
668
+ * backtick escapes the SPACE. Measured on pwsh 7 with a recording stub: pwsh
669
+ * runs `gh pr merge` with NO target (argv `[pr] [merge]`, so the current
670
+ * branch's PR merges) and evaluates the next line as its own statement. Here
671
+ * the backtick is not IMMEDIATELY followed by a newline, so the continuation
672
+ * arm does not take it, the separator arm does, and the walk projects an
673
+ * `unresolvedTarget` naming the backtick — a target nobody wrote, which the
674
+ * strict tier denies.
675
+ */
676
+ const ROW_PS_BACKTICK_SPACE_NEWLINE = 'gh pr merge ` \n5';
677
+ /**
678
+ * A disclosed FALSE FIRE (round-5 leg, F13). PowerShell's escape inside a
679
+ * double-quoted string is the BACKTICK, so `"a `"; gh pr merge 5`"b"` is ONE
680
+ * string to PowerShell — it prints text and merges nothing. This walk reads
681
+ * POSIX quoting for BOTH tools, so the `"` after the escaping backtick closes
682
+ * the string, the `;` ends a segment, and `gh pr merge 5` lands at the next
683
+ * segment's front. The row asserts what the wrapper DOES here, so the
684
+ * disclosure in the template is read from a row, never from memory.
685
+ */
686
+ const ROW_PS_DQ_BACKTICK = 'Write-Output "a `"; gh pr merge 5`"b"';
687
+ const ROW_OPEN_PAREN_COMMENT = '(#<<note\ngh pr merge 5\n)';
688
+ const ROW_GROUP_CLOSE_COMMENT = '(true; echo a)#<<note\ngh pr merge 5';
689
+ /**
690
+ * The delimiters mmnto-ai/totem#2857 names — each carrying a character outside
691
+ * the template's old word class — in a terminated and an unterminated form.
692
+ */
693
+ const PARITY_DELIMITERS = ['E:F', 'E*F', 'E+F', 'E=F', 'E,F', 'E@F', 'E!F', 'EOF~'];
694
+ const DELIMITER_PARITY_ROWS = PARITY_DELIMITERS.flatMap((d) => [
695
+ `cat <<${d}\nbody\n${d}\ngh pr merge 5`,
696
+ `cat <<${d}\nbody\ngh pr merge 5`,
697
+ ]);
698
+ /** Every string above, the corpus half of the parity lock. */
699
+ const PARITY_COMMAND_CORPUS = [
700
+ ...COMMAND_POSITION_ROWS,
701
+ ...NEVER_SPAWNS_ROWS,
702
+ ...EXECUTABLE_SPELLING_ROWS,
703
+ ...WRAPPER_STRIP_ROWS,
704
+ ...REDIRECTION_ROWS,
705
+ ...MUTANT_ROWS,
706
+ ...INVALID_OPTION_FALSE_FIRE_ROWS,
707
+ ...ARITHMETIC_COMMENT_ROWS,
708
+ ...HERESTRING_ROWS,
709
+ ...LINE_CONTINUATION_ROWS,
710
+ ...PS_LINE_CONTINUATION_ROWS,
711
+ ...PS_CONTINUATION_INSIDE_WORD_ROWS,
712
+ ROW_PS_TRAILING_BACKTICK,
713
+ ROW_ENV_S_UNEXPANDED,
714
+ ...DELIMITER_PARITY_ROWS,
715
+ ROW_TWO_HEREDOC_BODIES,
716
+ ROW_HEREDOC_AS_OPERAND,
717
+ ROW_MERGE_AFTER_HEREDOC,
718
+ ROW_PS_BLOCK_MULTILINE,
719
+ ROW_PS_BLOCK_INLINE,
720
+ ROW_PS_BLOCK_THEN_MERGE,
721
+ ROW_BASH_HASH_REDIRECT,
722
+ ROW_SUBSTITUTION_COMMENT,
723
+ ROW_HERESTRING_AS_OPERAND,
724
+ ROW_TRAILING_COMMENT,
725
+ ROW_COMMENT_SEPARATOR,
726
+ ROW_COMMENT_BACKTICK,
727
+ ROW_PAREN_COMMENT_HEREDOC,
728
+ ROW_COLON_DELIMITER,
729
+ ROW_OPEN_PAREN_COMMENT,
730
+ ROW_GROUP_CLOSE_COMMENT,
731
+ ROW_PS_CALL_OPERATOR,
732
+ ROW_PS_DQ_BACKTICK,
733
+ ROW_TRAILING_REDIRECT_BRANCH,
734
+ ROW_TRAILING_REDIRECT_ERR,
735
+ ROW_TRAILING_REDIRECT_PR,
736
+ ROW_LITERAL_BODY_PR,
737
+ ROW_LITERAL_BODY_REPO,
738
+ ROW_LITERAL_SUBJECT_REPO,
739
+ ROW_LITERAL_ESCAPED_BODY,
740
+ ROW_QUOTED_REDIRECT_BRANCH,
741
+ ROW_QUOTED_REDIRECT_ERR,
742
+ ROW_QUOTED_APPEND_BRANCH,
743
+ ROW_QUOTED_VAR_BRANCH,
744
+ ROW_QUOTED_REDIRECT_PR,
745
+ ROW_QUOTED_OPERATOR_DATA,
746
+ ROW_WS_REDIRECT_BRANCH,
747
+ ROW_WS_HERESTRING_BRANCH,
748
+ ROW_EMPTY_QUOTE_ABUT,
749
+ ROW_QUOTED_OPERATOR_ABUT,
750
+ ROW_ABUT_REDIRECT_BRANCH,
751
+ ROW_LITERAL_BODY_SPACED,
752
+ ROW_EMPTY_PAIR_IN_OPERATOR_LEAD,
753
+ ROW_EMPTY_PAIR_IN_OPERATOR,
754
+ ROW_PS_BACKTICK_SPACE_NEWLINE,
755
+ ];
74
756
  function readSettings(cwd) {
75
757
  const raw = fs.readFileSync(path.join(cwd, '.claude', 'settings.json'), 'utf-8');
76
758
  return JSON.parse(raw);
@@ -839,36 +1521,54 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
839
1521
  expect(spawnedPayload()).toMatchObject({ pr: null, branch: 'feat/other-branch' });
840
1522
  });
841
1523
  it('a value-taking flag does not swallow the PR target (`-b "…" 42`)', () => {
842
- initGitRepo();
1524
+ const head = initGitRepo();
843
1525
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
844
1526
  runWrapper(bash('gh pr merge -b "merge this now" 42'), [], 'merge-ready');
845
1527
  expect(spawnedPayload()).toMatchObject({ pr: 42 });
1528
+ // WIDENED to a body WITHOUT spaces (round-6 leg, G1): a multi-word body
1529
+ // can never look like anything else, so this row held the pairing only
1530
+ // for bodies the whole-segment redirection strip would not touch. A
1531
+ // one-word body that reads as a redirection (`<br>`) is where the strip
1532
+ // and the pairing collide.
1533
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1534
+ runWrapper(bash(ROW_LITERAL_BODY_PR), [], 'merge-ready');
1535
+ expect(spawnedPayload(), ROW_LITERAL_BODY_PR).toEqual({
1536
+ repo: 'mmnto-ai/totem',
1537
+ pr: 5,
1538
+ headSha: head,
1539
+ });
1540
+ });
1541
+ it('a QUOTED or escaped redirection lookalike is an argument, not an operator (round-6 leg, G1)', () => {
1542
+ // The redirection strip reads a token's text, so `-b "<br>"` and
1543
+ // `-t ">>"` — the merge's own data, quoted by the author for exactly
1544
+ // this reason — were dropped as operators. The fused one took the body
1545
+ // and left `-b` to swallow the PR number; the `>>` ALONE took the token
1546
+ // after it, which was `--repo`, and the payload named the wrong
1547
+ // repository. The tokenizer now marks a token whose text came from
1548
+ // inside quotes or from a backslash escape, and the strip skips it.
1549
+ const head = initGitRepo();
1550
+ for (const command of [ROW_LITERAL_BODY_REPO, ROW_LITERAL_SUBJECT_REPO]) {
1551
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1552
+ runWrapper(bash(command), [], 'merge-ready');
1553
+ expect(spawnedPayload(), command).toEqual({
1554
+ repo: 'owner/name',
1555
+ pr: 5,
1556
+ headSha: head,
1557
+ });
1558
+ }
1559
+ // A backslash escape is the other half of the rule: `\<br\>` is the same
1560
+ // data spelled without quotes.
1561
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1562
+ runWrapper(bash(ROW_LITERAL_ESCAPED_BODY), [], 'merge-ready');
1563
+ expect(spawnedPayload(), ROW_LITERAL_ESCAPED_BODY).toEqual({
1564
+ repo: 'mmnto-ai/totem',
1565
+ pr: 5,
1566
+ headSha: head,
1567
+ });
846
1568
  });
847
1569
  it("fires at command position after a separator, after the shell's command-position words, and behind an assignment prefix", () => {
848
1570
  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
- ]) {
1571
+ for (const command of COMMAND_POSITION_ROWS) {
872
1572
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
873
1573
  runWrapper(bash(command), [], 'merge-ready');
874
1574
  expect(spawnedPayload(), command).toMatchObject({ pr: 7 });
@@ -876,53 +1576,89 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
876
1576
  });
877
1577
  it('does NOT fire inside a quoted string, a heredoc body, or on another gh verb — and never spawns', () => {
878
1578
  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 itPR 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
- // The pilot-install round (greptile P1 on mmnto-ai/totem#2855): the
911
- // executable spelled with an extension or a path is not the bare token
912
- // the anchor reads disclosed here and in the template's comment;
913
- // widening the token is mmnto-ai/totem#2856, the strict tier's precondition.
914
- 'gh.exe pr merge 5',
915
- './gh pr merge 5',
916
- // The same round's legs (mmnto-ai/totem#2857): two divergences from
917
- // core's scanner that open a heredoc core does not, so the merge on a
918
- // later line is blanked — a comment after `(` or an operator `)` (the
919
- // template has no paren-boundary arms), and a bare delimiter carrying a
920
- // character outside the template's word class (`<<E:F`) parsed as a
921
- // prefix so the real terminator never matches. Locked as misses here;
922
- // a fix flips these rows to spawnedPayload() rows.
923
- '(true)#<<note\ngh pr merge 5',
924
- 'cat <<E:F\nbody\nE:F\ngh pr merge 5',
925
- ]) {
1579
+ for (const command of NEVER_SPAWNS_ROWS) {
1580
+ writeStubCli({
1581
+ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} },
1582
+ });
1583
+ const { status } = runWrapper(bash(command), [], 'merge-ready');
1584
+ expect(status, command).toBe(0);
1585
+ expect(stubArgv(), command).toBeNull();
1586
+ }
1587
+ });
1588
+ it('the executable may carry an extension or a path (mmnto-ai/totem#2856 § A)', () => {
1589
+ // The anchor read the bare token `gh`, so every other spelling of the SAME
1590
+ // executable ran unjudged a bypass under the strict tier (greptile P1 on
1591
+ // mmnto-ai/totem#2855). The basename after the last `/` or `\` is what the
1592
+ // test reads now.
1593
+ // The WHOLE payload, not a subset (round-5 leg, F11): a `toMatchObject`
1594
+ // on `{ pr: 5 }` passes on a payload that also carries a branch, an
1595
+ // `unresolvedTarget` or a repo from the wrong armeach of which the
1596
+ // engine judges differently. The clause is `{ repo, pr, headSha }`.
1597
+ const head = initGitRepo();
1598
+ for (const command of EXECUTABLE_SPELLING_ROWS) {
1599
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1600
+ runWrapper(bash(command), [], 'merge-ready');
1601
+ expect(spawnedPayload(), command).toEqual({
1602
+ repo: 'mmnto-ai/totem',
1603
+ pr: 5,
1604
+ headSha: head,
1605
+ });
1606
+ }
1607
+ });
1608
+ it('a transparent wrapper program and a flag-carrying builtin are stripped (mmnto-ai/totem#2856 § B)', () => {
1609
+ // A wrapper PROGRAM took the segment's first token, so the anchor never
1610
+ // saw `gh` and the merge ran unjudged. The strip now consumes a CLOSED
1611
+ // list of transparent programs with their option grammar, re-runs the
1612
+ // assignment strip after them, and only then reads the executable.
1613
+ const head = initGitRepo();
1614
+ for (const command of WRAPPER_STRIP_ROWS) {
1615
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1616
+ runWrapper(bash(command), [], 'merge-ready');
1617
+ expect(spawnedPayload(), command).toEqual({
1618
+ repo: 'mmnto-ai/totem',
1619
+ pr: 5,
1620
+ headSha: head,
1621
+ });
1622
+ }
1623
+ });
1624
+ it('a leading redirection and a backtick substitution are judged (mmnto-ai/totem#2856 § C)', () => {
1625
+ // The shell applies a leading redirection and runs what follows, and the
1626
+ // operand of a backtick substitution IS a command — both ran unjudged
1627
+ // while the redirection word was the segment's first token and the
1628
+ // backtick was an ordinary character.
1629
+ const head = initGitRepo();
1630
+ for (const command of REDIRECTION_ROWS) {
1631
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1632
+ runWrapper(bash(command), [], 'merge-ready');
1633
+ expect(spawnedPayload(), command).toEqual({
1634
+ repo: 'mmnto-ai/totem',
1635
+ pr: 5,
1636
+ headSha: head,
1637
+ });
1638
+ }
1639
+ // PowerShell's CALL OPERATOR is not a miss and the template says so from
1640
+ // this row, never from memory (§ F): `&` is one of the tokenizer's
1641
+ // segment separators, so the segment after it starts at `gh`.
1642
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1643
+ runWrapper({ tool_name: 'PowerShell', tool_input: { command: ROW_PS_CALL_OPERATOR } }, [], 'merge-ready');
1644
+ expect(spawnedPayload(), ROW_PS_CALL_OPERATOR).toEqual({
1645
+ repo: 'mmnto-ai/totem',
1646
+ pr: 5,
1647
+ headSha: head,
1648
+ });
1649
+ // The complement: a backtick substitution as the merge's own TARGET is a
1650
+ // target this hook cannot know, exactly as `$( … )` is — it rides as
1651
+ // `unresolvedTarget` rather than falling back to the current branch.
1652
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1653
+ runWrapper(bash('gh pr merge `cat pr.txt`'), [], 'merge-ready');
1654
+ const payload = spawnedPayload();
1655
+ expect(payload.pr).toBeNull();
1656
+ expect(payload.unresolvedTarget).toBe('`');
1657
+ expect(payload.branch).toBeUndefined();
1658
+ });
1659
+ it('every widened form has a MUTANT that must NOT project, and never spawns (mmnto-ai/totem#2856 § F)', () => {
1660
+ initGitRepo();
1661
+ for (const command of MUTANT_ROWS) {
926
1662
  writeStubCli({
927
1663
  verdict: { disposition: 'deny', reason: 'should not run', provenance: {} },
928
1664
  });
@@ -931,6 +1667,33 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
931
1667
  expect(stubArgv(), command).toBeNull();
932
1668
  }
933
1669
  });
1670
+ it('the two scanner divergences from mmnto-ai/totem#2855 now project (mmnto-ai/totem#2857 § 4)', () => {
1671
+ // These are the MUTANT PROOF for the parity lock at the foot of this
1672
+ // file: each one is a heredoc the template's hand-copied scanner opened
1673
+ // and core's does not, so the merge on the following line was blanked
1674
+ // and ran unjudged — one lost advisory read under PILOT, a bypass under
1675
+ // STRICT. `(true)#<<note` needs the paren-boundary arms (an operator `)`
1676
+ // ends a word, so the `#` after it begins a comment); `<<E:F` needs
1677
+ // core's bare-delimiter class (`[^\s'"\\<>()|&;]+`), where the template's
1678
+ // narrower one read the delimiter as the prefix `E` so the terminator
1679
+ // line never matched and the body ran to the end of the command.
1680
+ initGitRepo();
1681
+ for (const command of [
1682
+ ROW_PAREN_COMMENT_HEREDOC,
1683
+ ROW_COLON_DELIMITER,
1684
+ // Beside them, the other two shapes the paren arms decide: a `#` right
1685
+ // after an OPENING `(` (which begins a word), and one after the `)` of
1686
+ // a multi-command group. Neither is a comment without those arms, and
1687
+ // the `<<note` inside each opened a body that blanked the merge.
1688
+ ROW_OPEN_PAREN_COMMENT,
1689
+ ROW_GROUP_CLOSE_COMMENT,
1690
+ ]) {
1691
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1692
+ runWrapper(bash(command), [], 'merge-ready');
1693
+ expect(stubArgv(), JSON.stringify(command)).not.toBeNull();
1694
+ expect(spawnedPayload(), JSON.stringify(command)).toMatchObject({ pr: 5 });
1695
+ }
1696
+ });
934
1697
  it('every gh pr merge at command position is judged, not only the first (PR round 1, greptile)', () => {
935
1698
  initGitRepo();
936
1699
  // The stub overwrites its record on every spawn, so the record names the
@@ -960,13 +1723,7 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
960
1723
  // the real merge after it went unjudged — a silent miss on the exact
961
1724
  // command this gate exists for.
962
1725
  initGitRepo();
963
- for (const command of [
964
- 'echo $((1<<2)); gh pr merge 5',
965
- 'echo $(( 3<<1 )); gh pr merge 5',
966
- '# see <<note\ngh pr merge 5',
967
- '(( 1<<3 ))\ngh pr merge 5',
968
- 'echo hi # <<EOF\ngh pr merge 5',
969
- ]) {
1726
+ for (const command of ARITHMETIC_COMMENT_ROWS) {
970
1727
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
971
1728
  runWrapper(bash(command), [], 'merge-ready');
972
1729
  expect(spawnedPayload(), command).toMatchObject({ pr: 5 });
@@ -977,7 +1734,7 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
977
1734
  // the newline into the token left the segment starting with something
978
1735
  // other than `gh`, so a real merge went unjudged.
979
1736
  initGitRepo();
980
- for (const command of ['gh \\\npr merge 5', 'gh pr merge \\\n5', 'gh \\\r\npr merge 5']) {
1737
+ for (const command of LINE_CONTINUATION_ROWS) {
981
1738
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
982
1739
  runWrapper(bash(command), [], 'merge-ready');
983
1740
  expect(spawnedPayload(), JSON.stringify(command)).toMatchObject({ pr: 5 });
@@ -988,40 +1745,265 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
988
1745
  runWrapper(bash('gh pr merge 5 --body a\\ b'), [], 'merge-ready');
989
1746
  expect(spawnedPayload()).toMatchObject({ pr: 5 });
990
1747
  });
1748
+ it("PowerShell's line continuation joins the LINE at a word boundary, in ps mode only (round-6 leg, G3; round-7 leg, H4)", () => {
1749
+ // A trailing backtick is PowerShell's continuation, the twin of bash's
1750
+ // trailing backslash where no token is open. Read as the segment
1751
+ // separator it is in bash, the
1752
+ // merge's target became the backtick itself: the payload carried
1753
+ // `unresolvedTarget: '`'` (strict denies a target nobody wrote) and the
1754
+ // real target sat in the next segment, merged unjudged.
1755
+ const head = initGitRepo();
1756
+ for (const command of PS_LINE_CONTINUATION_ROWS) {
1757
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1758
+ runWrapper({ tool_name: 'PowerShell', tool_input: { command } }, [], 'merge-ready');
1759
+ expect(spawnedPayload(), JSON.stringify(command)).toEqual({
1760
+ repo: 'mmnto-ai/totem',
1761
+ pr: 5,
1762
+ headSha: head,
1763
+ });
1764
+ }
1765
+ // INSIDE A WORD it is not a join at all (round-7 leg, H4): PowerShell's
1766
+ // backtick escapes the newline into the argument, so `merg<LF>e` is not
1767
+ // `merge` and pwsh runs nothing. Projecting there was a merge the shell
1768
+ // never performs — and it never spawns now.
1769
+ for (const command of PS_CONTINUATION_INSIDE_WORD_ROWS) {
1770
+ writeStubCli({
1771
+ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} },
1772
+ });
1773
+ const { status } = runWrapper({ tool_name: 'PowerShell', tool_input: { command } }, [], 'merge-ready');
1774
+ expect(status, JSON.stringify(command)).toBe(0);
1775
+ expect(stubArgv(), JSON.stringify(command)).toBeNull();
1776
+ }
1777
+ // BASH is untouched: there a backtick opens a command substitution, so
1778
+ // it stays a segment separator and the target is unresolvable.
1779
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1780
+ runWrapper(bash(PS_LINE_CONTINUATION_ROWS[0]), [], 'merge-ready');
1781
+ const payload = spawnedPayload();
1782
+ expect(payload.pr).toBeNull();
1783
+ expect(payload.unresolvedTarget).toBe('`');
1784
+ });
991
1785
  it('a PowerShell block comment is data, not commands (round 3 F8; round 4 F1, F8)', () => {
992
1786
  initGitRepo();
993
1787
  const pwsh = (command) => ({
994
1788
  tool_name: 'PowerShell',
995
1789
  tool_input: { command },
996
1790
  });
997
- // THE CONTROL (round 4, F1): a MULTI-LINE block comment. Without the
998
- // `<#` arm this fires with pr 9 verified by stripping the arm from a
999
- // rendered copy. The single-line row below behaves the same either way
1000
- // (the `#` word-comment arm already covers it), so it is a companion, not
1001
- // a control.
1791
+ // TWO CONTROLS (round 4, F1; the second corrected by the round-5 leg's
1792
+ // F10). On a rendered copy with the `<#` arm stripped, BOTH the
1793
+ // multi-line and the inline row fire with pr 9 — the inline one because
1794
+ // its `<#` then reads as a fused REDIRECTION and drops, leaving
1795
+ // `gh pr merge 9` at the segment's front, and its trailing `#>` is eaten
1796
+ // by the `#` word-comment arm. The note this replaces called the inline
1797
+ // row a companion the `#` word-comment arm "already covers": it does not
1798
+ // — nothing there begins a word, so that `#` is text.
1002
1799
  writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1003
- expect(runWrapper(pwsh('<#\ngh pr merge 9\n#>\necho hi'), [], 'merge-ready').status).toBe(0);
1800
+ expect(runWrapper(pwsh(ROW_PS_BLOCK_MULTILINE), [], 'merge-ready').status).toBe(0);
1004
1801
  expect(stubArgv()).toBeNull();
1005
1802
  writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1006
- expect(runWrapper(pwsh('<# gh pr merge 9 #>\necho hi'), [], 'merge-ready').status).toBe(0);
1803
+ expect(runWrapper(pwsh(ROW_PS_BLOCK_INLINE), [], 'merge-ready').status).toBe(0);
1007
1804
  expect(stubArgv()).toBeNull();
1008
1805
  // The merge AFTER one is still judged — the blank must not eat it.
1009
1806
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1010
- runWrapper(pwsh('<# notes #>\ngh pr merge 4'), [], 'merge-ready');
1807
+ runWrapper(pwsh(ROW_PS_BLOCK_THEN_MERGE), [], 'merge-ready');
1011
1808
  expect(spawnedPayload()).toMatchObject({ pr: 4 });
1012
1809
  // ROUND 4 F8: the blank is a POWERSHELL rule. In bash `<#tmp` is a
1013
1810
  // redirect from a file named `#tmp`, and blanking from it to a later `#>`
1014
1811
  // would swallow the real merge on the next line.
1015
1812
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1016
- runWrapper(bash('sort <#tmp\ngh pr merge 8'), [], 'merge-ready');
1813
+ runWrapper(bash(ROW_BASH_HASH_REDIRECT), [], 'merge-ready');
1017
1814
  expect(spawnedPayload()).toMatchObject({ pr: 8 });
1018
1815
  });
1816
+ it('a trailing redirection never rides into argv as the merge target (round-5 leg, F5 + F12)', () => {
1817
+ // `gh pr merge --squash > merge.log` merges the CURRENT branch's PR and
1818
+ // writes gh's output to a file. With the strip reading only the
1819
+ // segment's FRONT, the `>` arrived as the merge's first positional: the
1820
+ // payload named a branch `>`, and the engine denied a pull request on a
1821
+ // branch no one wrote — a deny with a FALSE reason, the worst shape a
1822
+ // gate can have. The strip now runs over the whole segment.
1823
+ // The four QUOTED-FILENAME spellings join them (round-7 leg, H1/H2):
1824
+ // the operator characters are bare in each, so bash redirects and merges
1825
+ // the current branch's PR — while the round-6 literal rule read the
1826
+ // whole word as data and put `>merge.log`, `2>err.log`, `>>out.txt` and
1827
+ // `>$FILE` into argv as the merge's target.
1828
+ const head = initGitRepo();
1829
+ for (const command of [
1830
+ ROW_TRAILING_REDIRECT_BRANCH,
1831
+ ROW_TRAILING_REDIRECT_ERR,
1832
+ ROW_QUOTED_REDIRECT_BRANCH,
1833
+ ROW_QUOTED_REDIRECT_ERR,
1834
+ ROW_QUOTED_APPEND_BRANCH,
1835
+ ROW_QUOTED_VAR_BRANCH,
1836
+ // And the two whose quoted filename carries WHITESPACE (round-8 leg,
1837
+ // J1): on the fold-4 hook the fused pattern's filename class excluded
1838
+ // whitespace, so neither word was stripped and the engine was handed a
1839
+ // branch named `>merge log.txt` and `<<<bar baz`.
1840
+ ROW_WS_REDIRECT_BRANCH,
1841
+ ROW_WS_HERESTRING_BRANCH,
1842
+ // And the one whose QUOTE sits inside the operator prefix (fold 6):
1843
+ // bash reads the bare `>` with `>merge.log` as its filename and merges
1844
+ // the current branch's PR, while the fold-5 hook's greedy `>>` ended
1845
+ // past the first literal index and the engine read a branch named
1846
+ // `>>merge.log`.
1847
+ ROW_ABUT_REDIRECT_BRANCH,
1848
+ ]) {
1849
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1850
+ runWrapper(bash(command), [], 'merge-ready');
1851
+ expect(spawnedPayload(), command).toEqual({
1852
+ repo: 'mmnto-ai/totem',
1853
+ pr: null,
1854
+ branch: 'feat/demo',
1855
+ headSha: head,
1856
+ });
1857
+ }
1858
+ // And with a PR named, the number is still the target and nothing of the
1859
+ // redirection reaches the payload. This third row does NOT discriminate
1860
+ // as a payload (round-6 leg, G7): with the strip removed the argv is
1861
+ // `['5', '>', 'out.txt']` and `projectMergeReady` keeps the FIRST
1862
+ // positional, so the payload reads `pr: 5` either way. Its bite is the
1863
+ // exact-argv row in the export seam; it stays here as the complement to
1864
+ // the two branch rows above, not as a sensor.
1865
+ for (const command of [ROW_TRAILING_REDIRECT_PR, ROW_QUOTED_REDIRECT_PR]) {
1866
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1867
+ runWrapper(bash(command), [], 'merge-ready');
1868
+ expect(spawnedPayload(), command).toEqual({
1869
+ repo: 'mmnto-ai/totem',
1870
+ pr: 5,
1871
+ headSha: head,
1872
+ });
1873
+ }
1874
+ // And the LOCKED complement of the rule: with the OPERATOR quoted the
1875
+ // word is an ARGUMENT, so bash hands gh the string `>out.txt` and
1876
+ // redirects nothing. The payload names it as the target because that is
1877
+ // exactly what gh receives — stripping it here would be the mirror error
1878
+ // of the one above, dropping data the shell really passes to the program.
1879
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1880
+ runWrapper(bash(ROW_QUOTED_OPERATOR_DATA), [], 'merge-ready');
1881
+ expect(spawnedPayload(), ROW_QUOTED_OPERATOR_DATA).toEqual({
1882
+ repo: 'mmnto-ai/totem',
1883
+ pr: null,
1884
+ branch: '>out.txt',
1885
+ headSha: head,
1886
+ });
1887
+ // The RESIDUE of the same rule, asserted rather than claimed (round-8
1888
+ // leg, J5): an empty quote pair or a quoted operator ABUTTING a real
1889
+ // one. Bash passes the quoted part as an argument and redirects the
1890
+ // rest — `[--squash] []` and `[--squash] [>]` with a recording stub —
1891
+ // while this walk keeps each as one word. The divergence is in the
1892
+ // argv's TEXT: the branch it names is one the engine denies either way,
1893
+ // so it costs a bogus deny and lets no merge through.
1894
+ for (const [command, branch] of [
1895
+ [ROW_EMPTY_QUOTE_ABUT, '>out.txt'],
1896
+ [ROW_QUOTED_OPERATOR_ABUT, '>>out.txt'],
1897
+ ]) {
1898
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1899
+ runWrapper(bash(command), [], 'merge-ready');
1900
+ expect(spawnedPayload(), command).toEqual({
1901
+ repo: 'mmnto-ai/totem',
1902
+ pr: null,
1903
+ branch,
1904
+ headSha: head,
1905
+ });
1906
+ }
1907
+ });
1908
+ it('an INVALID option to a table word is a DISCLOSED false fire (round-6 leg, G4)', () => {
1909
+ // Each of these makes the program answer "invalid option" and run
1910
+ // nothing, while the strip reads the unknown `-` token as one of the
1911
+ // word's own options and judges a merge the shell never runs. The
1912
+ // alternative — a closed flag list per program — was measured on a
1913
+ // mutant and turns real flags the list omits (`sudo -n`, `sudo -E`,
1914
+ // `timeout --foreground`) into MISSES, which is a bypass under strict.
1915
+ // A false fire on a command that runs nothing costs one bogus deny.
1916
+ const head = initGitRepo();
1917
+ for (const command of INVALID_OPTION_FALSE_FIRE_ROWS) {
1918
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1919
+ runWrapper(bash(command), [], 'merge-ready');
1920
+ expect(spawnedPayload(), command).toEqual({
1921
+ repo: 'mmnto-ai/totem',
1922
+ pr: 5,
1923
+ headSha: head,
1924
+ });
1925
+ }
1926
+ });
1927
+ it('an `env -S` operand env itself REFUSES is a DISCLOSED false fire (round-7 leg, H5)', () => {
1928
+ // env expands only `${VARNAME}` inside a `-S` string: `$PR` makes it
1929
+ // refuse the whole command and run nothing. The re-entry splits the
1930
+ // operand on whitespace, so the anchor is reached and `$PR` rides as an
1931
+ // unresolvable target — which the strict tier denies. A deny on a merge
1932
+ // that never happens is the safe direction, and the residue paragraph
1933
+ // is read from this row rather than from memory.
1934
+ const head = initGitRepo();
1935
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1936
+ runWrapper(bash(ROW_ENV_S_UNEXPANDED), [], 'merge-ready');
1937
+ expect(spawnedPayload(), ROW_ENV_S_UNEXPANDED).toEqual({
1938
+ repo: 'mmnto-ai/totem',
1939
+ pr: null,
1940
+ unresolvedTarget: '$PR',
1941
+ headSha: head,
1942
+ });
1943
+ });
1944
+ it('a PowerShell backtick escape inside double quotes is a DISCLOSED false fire (round-5 leg, F13)', () => {
1945
+ // PowerShell escapes with a backtick inside a double-quoted string, so
1946
+ // `"a `"; gh pr merge 5`"b"` is ONE string and PowerShell merges nothing.
1947
+ // This walk reads POSIX quoting for BOTH tools — the `"` after the
1948
+ // escaping backtick closes the string and `gh pr merge 5` reaches a
1949
+ // segment's front — so the wrapper judges a merge the shell never runs.
1950
+ // The deny direction on contrived text, disclosed in the template's
1951
+ // false-fires paragraph and asserted here rather than claimed there.
1952
+ const head = initGitRepo();
1953
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1954
+ runWrapper({ tool_name: 'PowerShell', tool_input: { command: ROW_PS_DQ_BACKTICK } }, [], 'merge-ready');
1955
+ expect(spawnedPayload(), ROW_PS_DQ_BACKTICK).toEqual({
1956
+ repo: 'mmnto-ai/totem',
1957
+ pr: 5,
1958
+ headSha: head,
1959
+ });
1960
+ // The second of PowerShell's own (round-7 leg, H4): a trailing backtick
1961
+ // at the END of the input continues a line that does not exist. pwsh
1962
+ // fails to parse the command and runs nothing; here the backtick is not
1963
+ // followed by a newline, so the continuation arm does not take it, the
1964
+ // separator arm does, and the merge in front of it is judged. Asserted
1965
+ // rather than claimed, like every other entry in that paragraph.
1966
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1967
+ runWrapper({ tool_name: 'PowerShell', tool_input: { command: ROW_PS_TRAILING_BACKTICK } }, [], 'merge-ready');
1968
+ expect(spawnedPayload(), ROW_PS_TRAILING_BACKTICK).toEqual({
1969
+ repo: 'mmnto-ai/totem',
1970
+ pr: 5,
1971
+ headSha: head,
1972
+ });
1973
+ // The third of PowerShell's own (round-8 leg, J3), and the measurement
1974
+ // that narrows the one above: a backtick followed by WHITESPACE and then
1975
+ // a newline is not a continuation either — the backtick escapes the
1976
+ // space, pwsh runs `gh pr merge` with NO target (the current branch's PR
1977
+ // merges) and reads the next line as its own statement. Here the
1978
+ // backtick is not IMMEDIATELY followed by a newline, so the separator
1979
+ // arm takes it and the walk names the backtick as an unresolvable
1980
+ // target: a strict deny on a target nobody wrote.
1981
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1982
+ runWrapper({ tool_name: 'PowerShell', tool_input: { command: ROW_PS_BACKTICK_SPACE_NEWLINE } }, [], 'merge-ready');
1983
+ expect(spawnedPayload(), ROW_PS_BACKTICK_SPACE_NEWLINE).toEqual({
1984
+ repo: 'mmnto-ai/totem',
1985
+ pr: null,
1986
+ unresolvedTarget: '`',
1987
+ headSha: head,
1988
+ });
1989
+ });
1019
1990
  it('a comment is not a command: a merge inside one never fires', () => {
1991
+ // The first row is the plain shape; the other two are the ones that
1992
+ // DISCRIMINATE the blanking (round-5 leg, F9). Each of those carries a
1993
+ // tokenizer separator inside the comment, so on a rendered copy with the
1994
+ // blanker's comment loop removed they project pr 9 and pr 5 — while the
1995
+ // plain row projects nothing either way, because its merge never leaves
1996
+ // the `echo` segment. Without them this test held the comment arms to
1997
+ // nothing.
1020
1998
  initGitRepo();
1021
- writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1022
- const { status } = runWrapper(bash('echo hi # gh pr merge 9'), [], 'merge-ready');
1023
- expect(status).toBe(0);
1024
- expect(stubArgv()).toBeNull();
1999
+ for (const command of [ROW_TRAILING_COMMENT, ROW_COMMENT_SEPARATOR, ROW_COMMENT_BACKTICK]) {
2000
+ writeStubCli({
2001
+ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} },
2002
+ });
2003
+ const { status } = runWrapper(bash(command), [], 'merge-ready');
2004
+ expect(status, command).toBe(0);
2005
+ expect(stubArgv(), command).toBeNull();
2006
+ }
1025
2007
  });
1026
2008
  it('EVERY heredoc queued on a line is read as data, not just the first (fold round 2, F2)', () => {
1027
2009
  // bash reads `cat <<A <<B` as two bodies in order, so a command sitting
@@ -1029,12 +2011,12 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
1029
2011
  // commands — a false deny on text.
1030
2012
  initGitRepo();
1031
2013
  writeStubCli({ verdict: { disposition: 'deny', reason: 'should not run', provenance: {} } });
1032
- const { status } = runWrapper(bash('cat <<A <<B\nfirst\nA\ngh pr merge 5\nB\n'), [], 'merge-ready');
2014
+ const { status } = runWrapper(bash(ROW_TWO_HEREDOC_BODIES), [], 'merge-ready');
1033
2015
  expect(status).toBe(0);
1034
2016
  expect(stubArgv()).toBeNull();
1035
2017
  // The complement: a heredoc as the merge's OWN operand still fires.
1036
2018
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1037
- runWrapper(bash('gh pr merge 5 <<EOF\nnotes\nEOF\n'), [], 'merge-ready');
2019
+ runWrapper(bash(ROW_HEREDOC_AS_OPERAND), [], 'merge-ready');
1038
2020
  expect(spawnedPayload()).toMatchObject({ pr: 5 });
1039
2021
  });
1040
2022
  it('still fires on a real merge that FOLLOWS a heredoc (the blanker keeps the segments)', () => {
@@ -1042,7 +2024,7 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
1042
2024
  // terminator line ends the body and the next segment is judged normally.
1043
2025
  initGitRepo();
1044
2026
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1045
- runWrapper(bash('cat <<EOF > body.md\nsome release notes\nEOF\ngh pr merge 21'), [], 'merge-ready');
2027
+ runWrapper(bash(ROW_MERGE_AFTER_HEREDOC), [], 'merge-ready');
1046
2028
  expect(spawnedPayload()).toMatchObject({ pr: 21 });
1047
2029
  });
1048
2030
  it('a here-string is not a heredoc: a merge on the line after `<<<` still fires (CodeRabbit on mmnto-ai/totem#2855)', () => {
@@ -1054,11 +2036,7 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
1054
2036
  initGitRepo();
1055
2037
  // Three spellings, every one fail-open before the guard (the re-arm's R6):
1056
2038
  // spaced, glued, and a here-string that opens the command.
1057
- for (const command of [
1058
- 'grep x <<< bar\ngh pr merge 5',
1059
- 'grep x <<<bar\ngh pr merge 5',
1060
- '<<<bar\ngh pr merge 5',
1061
- ]) {
2039
+ for (const command of HERESTRING_ROWS) {
1062
2040
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1063
2041
  runWrapper(bash(command), [], 'merge-ready');
1064
2042
  expect(spawnedPayload(), command).toMatchObject({ pr: 5 });
@@ -1066,7 +2044,7 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
1066
2044
  // The complement (a non-regression row, not a falsifier): a here-string as
1067
2045
  // the merge's OWN operand still fires.
1068
2046
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1069
- runWrapper(bash('gh pr merge 6 <<< notes'), [], 'merge-ready');
2047
+ runWrapper(bash(ROW_HERESTRING_AS_OPERAND), [], 'merge-ready');
1070
2048
  expect(spawnedPayload()).toMatchObject({ pr: 6 });
1071
2049
  });
1072
2050
  it('a # glued to $( is a comment, as in core: a <<word inside it never opens a heredoc (the pilot-install re-arm, R2)', () => {
@@ -1077,7 +2055,7 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
1077
2055
  // merge on the next line — the same fail-open class as the here-string.
1078
2056
  initGitRepo();
1079
2057
  writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
1080
- runWrapper(bash('echo $(# <<note\ngh pr merge 5\n)'), [], 'merge-ready');
2058
+ runWrapper(bash(ROW_SUBSTITUTION_COMMENT), [], 'merge-ready');
1081
2059
  expect(spawnedPayload()).toMatchObject({ pr: 5 });
1082
2060
  });
1083
2061
  it('an unexpanded shell variable rides as unresolvedTarget, not as a branch (fold F13)', () => {
@@ -1115,6 +2093,153 @@ describe('gate-wrapper.cjs disposition → exit code', () => {
1115
2093
  runWrapper({ tool_name: 'PowerShell', tool_input: { command: 'gh pr merge 11' } }, [], 'merge-ready');
1116
2094
  expect(spawnedPayload()).toMatchObject({ pr: 11 });
1117
2095
  });
2096
+ /**
2097
+ * A `--require` preload that sleeps 8 s and exits 0 in any node process
2098
+ * whose executable IS the `git` shim, and is a no-op in every other node
2099
+ * process — the wrapper and the stub CLI run under the same NODE_OPTIONS,
2100
+ * so the argv0/execPath guard is what keeps the shim from slowing them.
2101
+ */
2102
+ const SLOW_GIT_PRELOAD = [
2103
+ '"use strict";',
2104
+ 'const path = require("path");',
2105
+ 'const base = (p) => path.basename(String(p || "")).toLowerCase();',
2106
+ 'const me = base(process.argv0) + "|" + base(process.execPath);',
2107
+ 'if (me.includes("git")) {',
2108
+ ' Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 8000);',
2109
+ ' process.exit(0);',
2110
+ '}',
2111
+ '',
2112
+ ].join('\n');
2113
+ /**
2114
+ * The slow-git shim on a PATH dir of its own: `spawnSync('git', …)`
2115
+ * without a shell resolves `git.exe` through PATH on win32 (a `.cmd` shim
2116
+ * is NOT resolvable that way), so the shim is a copy — a hard link where
2117
+ * the volume allows — of this node binary named `git`/`git.exe`, slowed by
2118
+ * the argv0-guarded preload above.
2119
+ */
2120
+ function slowGitEnv() {
2121
+ const binDir = path.join(cwd, 'slow-git-bin');
2122
+ fs.mkdirSync(binDir, { recursive: true });
2123
+ const shim = path.join(binDir, process.platform === 'win32' ? 'git.exe' : 'git');
2124
+ if (!fs.existsSync(shim)) {
2125
+ try {
2126
+ fs.linkSync(process.execPath, shim);
2127
+ }
2128
+ catch {
2129
+ fs.copyFileSync(process.execPath, shim);
2130
+ }
2131
+ if (process.platform !== 'win32')
2132
+ fs.chmodSync(shim, 0o755);
2133
+ }
2134
+ const preload = path.join(cwd, 'slow.cjs');
2135
+ fs.writeFileSync(preload, SLOW_GIT_PRELOAD);
2136
+ const env = envWithPath(binDir + path.delimiter + (process.env.PATH ?? ''));
2137
+ env.NODE_OPTIONS = '--require=' + preload.split(path.sep).join('/');
2138
+ return env;
2139
+ }
2140
+ /**
2141
+ * Spawn the rendered wrapper, write the envelope on its stdin and leave
2142
+ * the stream OPEN, then await the exit. The `end` never comes, so only the
2143
+ * wrapper's own budget can end the run — which is the whole assertion.
2144
+ * The kill after 10 s is the harness's own floor, not the wrapper's: if it
2145
+ * fires, the row has failed.
2146
+ */
2147
+ async function runWrapperOpenStdin(envelope, extraArgs, event) {
2148
+ const wrapperPath = path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs');
2149
+ const started = Date.now();
2150
+ const child = spawn(process.execPath, [wrapperPath, '--event', event, ...extraArgs], {
2151
+ cwd,
2152
+ stdio: ['pipe', 'pipe', 'pipe'],
2153
+ });
2154
+ let stderr = '';
2155
+ child.stderr.setEncoding('utf-8');
2156
+ child.stderr.on('data', (chunk) => {
2157
+ stderr += chunk;
2158
+ });
2159
+ child.stdout.resume();
2160
+ // A wrapper that exits while the pipe is still open makes this write
2161
+ // EPIPE; that is the expected end of this row, not a failure.
2162
+ child.stdin.on('error', () => { });
2163
+ child.stdin.write(JSON.stringify(envelope));
2164
+ const status = await new Promise((resolve, reject) => {
2165
+ const kill = setTimeout(() => {
2166
+ child.kill();
2167
+ reject(new Error('the wrapper did not exit on its own with stdin left open'));
2168
+ }, 10000);
2169
+ child.on('error', (err) => {
2170
+ clearTimeout(kill);
2171
+ reject(err);
2172
+ });
2173
+ child.on('close', (code) => {
2174
+ clearTimeout(kill);
2175
+ resolve(code);
2176
+ });
2177
+ });
2178
+ return { status, stderr, elapsed: Date.now() - started };
2179
+ }
2180
+ it('the stdin read is INSIDE the budget: an envelope whose pipe never closes exits 2 (round-5 leg, F6)', async () => {
2181
+ // The budget covered everything after the envelope ARRIVED; reading it
2182
+ // was outside. A host that writes the envelope and holds the pipe open
2183
+ // (or writes nothing at all) left this hook waiting with no deadline of
2184
+ // its own until the HOST killed it — and a killed hook's exit code is
2185
+ // never applied, a fail-OPEN on a gate whose posture is fail-closed, the
2186
+ // same class § D cured for the projection's git reads. The timer is armed
2187
+ // at the entry for what is left of the budget; the `end` handler clears
2188
+ // it before evaluating, so a normal run never sees it (every other row in
2189
+ // this file is that proof).
2190
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
2191
+ const { status, stderr, elapsed } = await runWrapperOpenStdin(bash('gh pr merge 5'), ['--budget-ms', '1000'], 'merge-ready');
2192
+ expect(status).toBe(2);
2193
+ expect(stderr).toContain('the 1000 ms budget was spent before the envelope arrived on stdin');
2194
+ expect(stderr).toContain('fail-closed');
2195
+ // Nothing was evaluated: the envelope never finished arriving.
2196
+ expect(stubArgv()).toBeNull();
2197
+ // It waited for the budget, and only for the budget.
2198
+ expect(elapsed).toBeGreaterThanOrEqual(900);
2199
+ expect(elapsed).toBeLessThan(5000);
2200
+ });
2201
+ it('the `--budget-ms=<n>` spelling parses like the separate-token form (round-5 leg, F7)', () => {
2202
+ // `--budget-ms=1500` fell through the argv loop as an unknown argument
2203
+ // and left the full 30 s default in place — silently WIDENING the window
2204
+ // for a caller that wrote the argument to shorten it, the one direction
2205
+ // this argument must never move. End-to-end against the same hung git as
2206
+ // the row above, so what is asserted is the wrapper's real arm.
2207
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
2208
+ const env = slowGitEnv();
2209
+ const started = Date.now();
2210
+ const { status, stderr } = runWrapper(bash('gh pr merge 5'), ['--budget-ms=1500'], 'merge-ready', env);
2211
+ const elapsed = Date.now() - started;
2212
+ expect(status).toBe(2);
2213
+ expect(stderr).toContain('the 1500 ms budget was spent before gate "merge-ready" could be evaluated');
2214
+ expect(stubArgv()).toBeNull();
2215
+ expect(elapsed).toBeLessThan(6000);
2216
+ });
2217
+ it('a hung git is bounded by the budget: exit 2, the named line, no CLI spawn (mmnto-ai/totem#2856 § G)', () => {
2218
+ // THE FALSIFIER for § D. `spawnSync('git', …)` without a shell resolves
2219
+ // `git.exe` through PATH on win32 (a `.cmd` shim is NOT resolvable that
2220
+ // way), so the shim is a copy — a hard link where the volume allows — of
2221
+ // this node binary named `git`/`git.exe` on a PATH dir of its own.
2222
+ //
2223
+ // Pre-fix the projection's git reads ran on their own 10 s timeouts
2224
+ // BEFORE any deadline existed: the wrapper spent ~20 s in the projection
2225
+ // and then spawned the CLI (and on a multi-merge envelope it reached the
2226
+ // host's own hook timeout, where its exit code is never applied). With
2227
+ // the budget set at the entry, the reads are bounded by what is left of
2228
+ // it and the spent-budget arm fires before the first `gate check`.
2229
+ writeStubCli({ verdict: ALLOW_VERDICT, exit: 0 });
2230
+ const env = slowGitEnv();
2231
+ const started = Date.now();
2232
+ const { status, stderr } = runWrapper(bash('gh pr merge 5'), ['--budget-ms', '1500'], 'merge-ready', env);
2233
+ const elapsed = Date.now() - started;
2234
+ expect(status).toBe(2);
2235
+ expect(stderr).toContain('the 1500 ms budget was spent before gate "merge-ready" could be evaluated');
2236
+ expect(stderr).toContain('fail-closed');
2237
+ // The CLI was never spawned: the budget was gone before the first check.
2238
+ expect(stubArgv()).toBeNull();
2239
+ // Bounded by the budget plus one floor, not by the 8 s sleep and not by
2240
+ // `runWrapper`'s own 30 s timeout.
2241
+ expect(elapsed).toBeLessThan(6000);
2242
+ });
1118
2243
  it('an evaluation failure on an APPLICABLE merge blocks (fail-closed), pilot exits 0', () => {
1119
2244
  initGitRepo();
1120
2245
  writeStubCli({ exit: 1 });
@@ -1427,4 +2552,538 @@ describe('init --gates= routes through the shared installer', () => {
1427
2552
  expect(fs.existsSync(path.join(cwd, '.claude', 'hooks', 'gate-wrapper.cjs'))).toBe(false);
1428
2553
  });
1429
2554
  });
2555
+ // ─── The export seam (mmnto-ai/totem#2856 § E) ─────────────────────────
2556
+ //
2557
+ // Run as a hook the wrapper IS the main module and runs its entry; `require`d
2558
+ // it runs no entry and exports the projection, the scanner and the budget
2559
+ // clamp. These rows drive those exports IN-PROCESS — a cell of the strip table
2560
+ // through a process spawn costs a second of wall time apiece, and the
2561
+ // end-to-end rows above already prove the seam and the hook agree.
2562
+ describe('gate-wrapper export seam (mmnto-ai/totem#2856 § E)', () => {
2563
+ it('a required wrapper exports the projection, the scanner and the clamp — and runs no entry', () => {
2564
+ const w = wrapperExports();
2565
+ for (const name of [
2566
+ 'blankHeredocBodies',
2567
+ 'clampBudgetMs',
2568
+ 'findHeredocSpans',
2569
+ 'ghPrMergeArgvs',
2570
+ 'isGhExecutable',
2571
+ 'projectMergeReady',
2572
+ ]) {
2573
+ expect(typeof w[name], name).toBe('function');
2574
+ }
2575
+ // The entry reads stdin and exits the process: requiring the module must do
2576
+ // NEITHER — reaching this line is that assertion — and the projection must
2577
+ // answer without a spawn.
2578
+ expect(w.ghPrMergeArgvs('gh pr merge 5', false)).toEqual([['5']]);
2579
+ });
2580
+ it('--budget-ms can only LOWER the budget (§ D)', () => {
2581
+ // A malformed or oversized test-only argument must never WIDEN the window
2582
+ // in which a hung git can run the hook into the host's own kill (where the
2583
+ // wrapper's fail-closed exit is never applied). The clamp is silent and
2584
+ // one-directional; the value it settled on is echoed in the budget line
2585
+ // when the arm fires.
2586
+ const { clampBudgetMs } = wrapperExports();
2587
+ const rows = [
2588
+ [1, 1000],
2589
+ ['abc', 30000],
2590
+ [99999, 30000],
2591
+ [1500, 1500],
2592
+ ['1500', 1500],
2593
+ [undefined, 30000],
2594
+ [null, 30000],
2595
+ ['', 30000],
2596
+ [0, 1000],
2597
+ [-5, 1000],
2598
+ [999, 1000],
2599
+ [1000, 1000],
2600
+ [30000, 30000],
2601
+ [30001, 30000],
2602
+ ['20000abc', 20000],
2603
+ [Number.NaN, 30000],
2604
+ [Number.POSITIVE_INFINITY, 30000],
2605
+ ];
2606
+ for (const [raw, expected] of rows) {
2607
+ expect(clampBudgetMs(raw), JSON.stringify(raw ?? String(raw))).toBe(expected);
2608
+ }
2609
+ });
2610
+ it('the strip table, cell by cell: what projects and what must not (§ B)', () => {
2611
+ const { ghPrMergeArgvs } = wrapperExports();
2612
+ /** [command, the argv after `gh pr merge`, or null when nothing projects] */
2613
+ const rows = [
2614
+ // sudo: `-a -c -u -g -p -C -D -h -R -r -t -T -U` each take a separate
2615
+ // operand, and so does each long spelling (round-5 leg, F2; `-R`, `-a`
2616
+ // and `-c` added on the mmnto-ai/totem#2894 bot round).
2617
+ ['sudo gh pr merge 5', ['5']],
2618
+ ['sudo -R /chroot gh pr merge 5', ['5']],
2619
+ ['sudo -a bsdauth -c staff gh pr merge 5', ['5']],
2620
+ ['sudo -u root gh pr merge 5', ['5']],
2621
+ ['sudo -g grp -p prompt gh pr merge 5', ['5']],
2622
+ ['sudo -H -E gh pr merge 5', ['5']],
2623
+ ['sudo -u gh pr merge 5', null],
2624
+ ['sudo --user root gh pr merge 5', ['5']],
2625
+ ['sudo --group grp --prompt p gh pr merge 5', ['5']],
2626
+ ['sudo --chdir /tmp gh pr merge 5', ['5']],
2627
+ ['sudo --chroot /r gh pr merge 5', ['5']],
2628
+ ['sudo --host h gh pr merge 5', ['5']],
2629
+ ['sudo --role r --type t gh pr merge 5', ['5']],
2630
+ ['sudo --other-user u gh pr merge 5', ['5']],
2631
+ ['sudo --command-timeout 10 gh pr merge 5', ['5']],
2632
+ ['sudo --user gh pr merge 5', null],
2633
+ // sudo's DESCRIBE-only options execute nothing (round-5 leg, F1).
2634
+ ['sudo -l gh pr merge 5', null],
2635
+ ['sudo --list gh pr merge 5', null],
2636
+ ['sudo -v gh pr merge 5', null],
2637
+ ['sudo --validate gh pr merge 5', null],
2638
+ ['sudo -V gh pr merge 5', null],
2639
+ ['sudo --version gh pr merge 5', null],
2640
+ ['sudo -K gh pr merge 5', null],
2641
+ ['sudo --remove-timestamp gh pr merge 5', null],
2642
+ // …but `-E` and `-b` still run the command.
2643
+ ['sudo -E gh pr merge 5', ['5']],
2644
+ ['sudo -b gh pr merge 5', ['5']],
2645
+ // env: options, then the assignment strip re-runs.
2646
+ ['env gh pr merge 5', ['5']],
2647
+ ['env A=1 B=2 gh pr merge 5', ['5']],
2648
+ ['env -u X A=1 gh pr merge 5', ['5']],
2649
+ ['env -C /tmp gh pr merge 5', ['5']],
2650
+ ['env -u gh pr merge 5', null],
2651
+ ['env --unset X gh pr merge 5', ['5']],
2652
+ ['env --chdir /tmp gh pr merge 5', ['5']],
2653
+ ['env --unset gh pr merge 5', null],
2654
+ // `-S` / `--split-string` carries the COMMAND as its operand: env splits
2655
+ // that string into words, prepends them to the arguments that follow and
2656
+ // runs the first word. Every row here RUNS `gh pr merge 5` — measured on
2657
+ // coreutils 8.32 with a stub `gh` — and every one of them projected
2658
+ // NOTHING on the fold-2 hook, where the operand was consumed with the
2659
+ // option (fold 3: consistency in the MISS direction was the wrong cure).
2660
+ // The words take the option's place now and the strip reads on from
2661
+ // them, so the assignment strip still runs and the anchor sees `gh`.
2662
+ ["env -S 'gh pr merge 5'", ['5']],
2663
+ ["env --split-string='gh pr merge 5'", ['5']],
2664
+ ["env --split-string 'gh pr merge 5'", ['5']],
2665
+ ['env -S gh pr merge 5', ['5']],
2666
+ ['env --split-string gh pr merge 5', ['5']],
2667
+ ['env -S "gh pr merge" 5', ['5']],
2668
+ ["env -u X -S 'gh pr merge 5'", ['5']],
2669
+ ["env -S 'A=1 gh pr merge 5'", ['5']],
2670
+ // The ATTACHED SHORT spelling carries its operand too (round-7 leg,
2671
+ // H5): a short option takes it with no separator, and the quote arm
2672
+ // joins `-S'…'` into the same token, so both of these arrive as
2673
+ // `-Sgh pr merge 5` and both RUN the merge (measured). Fold 3 dropped
2674
+ // the token as a flag of env and missed them.
2675
+ ['env -Sgh pr merge 5', ['5']],
2676
+ ["env -S'gh pr merge 5'", ['5']],
2677
+ // …but an `=` is NOT a separator for a short option. env reads the
2678
+ // operand `=gh pr merge 5`, takes `=gh` as an assignment with an empty
2679
+ // name and runs `pr merge 5` (coreutils `pr`: `pr: merge: No such file
2680
+ // or directory`). No merge runs, and the walk projects none — it reads
2681
+ // `=gh` as the command. The `=` split is LONG-option-only now; on the
2682
+ // fold-3 hook it applied here too and projected PR 5, a false fire.
2683
+ ["env -S='gh pr merge 5'", null],
2684
+ // The BOUNDARY of that split, locked: it is env's whitespace rule and
2685
+ // nothing else — no env escapes, no `$VAR`, no `#` comment, no quote
2686
+ // stripping inside the string. `\_` is a SPACE to env, so the first row
2687
+ // RUNS `gh pr merge 5` (measured) while this walk reads one word; the
2688
+ // second NESTS `-S`, and env strips the inner quotes and runs the merge
2689
+ // while this walk reads `"gh` as the executable. Both are fail-opens,
2690
+ // disclosed in the template's comment.
2691
+ ["env -S 'gh\\_pr\\_merge\\_5'", null],
2692
+ ['env -S \'env -S "gh pr merge 5"\'', null],
2693
+ // The operand env itself REFUSES: `$VAR` inside a `-S` string is an
2694
+ // error (`only ${VARNAME} expansion is supported`), so coreutils runs
2695
+ // nothing while this walk splits the words and reads `$PR` as an
2696
+ // unresolvable target — a disclosed FALSE FIRE, asserted end-to-end by
2697
+ // its own row below.
2698
+ ["env -S 'gh pr merge $PR'", ['$PR']],
2699
+ // timeout: exactly ONE positional (the duration) before the command.
2700
+ ['timeout 30 gh pr merge 5', ['5']],
2701
+ ['timeout -s TERM 30 gh pr merge 5', ['5']],
2702
+ ['timeout gh pr merge 5', null],
2703
+ ['timeout 30 sudo gh pr merge 5', ['5']],
2704
+ ['timeout --signal KILL 30 gh pr merge 5', ['5']],
2705
+ ['timeout --kill-after 5 30 gh pr merge 5', ['5']],
2706
+ ['timeout --signal 30 gh pr merge 5', null],
2707
+ // nice: `-n` takes an operand; a bare `-10` is an adjustment.
2708
+ ['nice gh pr merge 5', ['5']],
2709
+ ['nice -n 10 gh pr merge 5', ['5']],
2710
+ ['nice -10 gh pr merge 5', ['5']],
2711
+ ['nice -n gh pr merge 5', null],
2712
+ ['nice --adjustment 10 gh pr merge 5', ['5']],
2713
+ ['nice --adjustment gh pr merge 5', null],
2714
+ // nohup: no options of its own.
2715
+ ['nohup gh pr merge 5', ['5']],
2716
+ // command: `-p` is transparent, `-v`/`-V` describe and never execute.
2717
+ ['command gh pr merge 5', ['5']],
2718
+ ['command -p gh pr merge 5', ['5']],
2719
+ ['command -v gh pr merge 5', null],
2720
+ ['command -V gh pr merge 5', null],
2721
+ // exec: `-a` takes the argv[0] operand; `-c` and `-l` do not.
2722
+ ['exec gh pr merge 5', ['5']],
2723
+ ['exec -a x gh pr merge 5', ['5']],
2724
+ ['exec -c -l gh pr merge 5', ['5']],
2725
+ ['exec -a gh pr merge 5', null],
2726
+ // time: bash's RESERVED WORD, `time [-p] [--] pipeline`. `-p` is its one
2727
+ // option, `--` ends options, and ANY other `-` token is a command bash
2728
+ // cannot find — nothing runs, nothing projects (round-5 leg, F3). GNU
2729
+ // `/usr/bin/time`'s `-o`/`-f` grammar is a different program's, and a
2730
+ // path-spelled wrapper is not on the closed table.
2731
+ ['time gh pr merge 5', ['5']],
2732
+ ['time -p gh pr merge 5', ['5']],
2733
+ ['time -- gh pr merge 5', ['5']],
2734
+ ['time -o out.txt gh pr merge 5', null],
2735
+ ['time -f x gh pr merge 5', null],
2736
+ ['time -o gh pr merge 5', null],
2737
+ ['/usr/bin/time -f x gh pr merge 5', null],
2738
+ // eval: depth 1 only.
2739
+ ['eval gh pr merge 5', ['5']],
2740
+ ['eval "gh pr merge 5"', ['5']],
2741
+ ['eval "eval \\"gh pr merge 5\\""', null],
2742
+ // Not on the closed list.
2743
+ ['npx gh pr merge 5', null],
2744
+ ['xargs -n1 gh pr merge 5', null],
2745
+ ['watch gh pr merge 5', null],
2746
+ // The flags of the merge itself still ride through untouched.
2747
+ ['sudo gh pr merge 5 --squash', ['5', '--squash']],
2748
+ ];
2749
+ for (const [command, expected] of rows) {
2750
+ const found = ghPrMergeArgvs(command, false);
2751
+ expect(found, command).toEqual(expected === null ? [] : [expected]);
2752
+ }
2753
+ });
2754
+ it('a redirection is dropped with its file ANYWHERE in the segment (§ C, round-5 leg F5 + F12)', () => {
2755
+ // The argv the rows above can only assert through a payload, asserted
2756
+ // exactly: an operator alone takes the file token with it, a fused one
2757
+ // goes alone, and neither ever reaches `gh pr merge`'s argv.
2758
+ const { ghPrMergeArgvs } = wrapperExports();
2759
+ const rows = [
2760
+ // Trailing — the shape that rode `>` in as the merge's target. THIS is
2761
+ // where `ROW_TRAILING_REDIRECT_PR` discriminates (round-6 leg, G7):
2762
+ // measured on a copy of the hook with the strip removed, its argv is
2763
+ // `['5', '>', 'out.txt']` — while its PAYLOAD is `pr: 5` either way,
2764
+ // because a positional after the first is ignored. Its two branch-payload
2765
+ // siblings below bite end-to-end as well (`branch: '>'`, `branch: '2>'`);
2766
+ // this one bites only here.
2767
+ [ROW_TRAILING_REDIRECT_PR, ['5']],
2768
+ ['gh pr merge 5 >out.txt', ['5']],
2769
+ [ROW_TRAILING_REDIRECT_BRANCH, ['--squash']],
2770
+ [ROW_TRAILING_REDIRECT_ERR, ['--squash']],
2771
+ // Between the executable and its verb — the shape that broke the anchor.
2772
+ ['gh > out.txt pr merge 5', ['5']],
2773
+ ['gh pr > out.txt merge 5', ['5']],
2774
+ // Leading, alone and fused, in every spelling the two patterns read.
2775
+ ['> out.txt gh pr merge 5', ['5']],
2776
+ ['2>/dev/null gh pr merge 5', ['5']],
2777
+ ['<<<bar gh pr merge 5', ['5']],
2778
+ ['<<< bar gh pr merge 5', ['5']],
2779
+ ['2<> file gh pr merge 5', ['5']],
2780
+ ['2<>file gh pr merge 5', ['5']],
2781
+ // A `<<EOF` head is a fused form and drops harmlessly — its body was
2782
+ // blanked by the scanner long before the tokenizer ran.
2783
+ [ROW_HEREDOC_AS_OPERAND, ['5']],
2784
+ [ROW_HERESTRING_AS_OPERAND, ['6']],
2785
+ // QUOTED or ESCAPED OPERATOR, so not an operator at all (round-6 leg,
2786
+ // G1, corrected by round-7's H1): the argv asserted exactly, which is
2787
+ // what the payload rows can only imply. Without the literal index these
2788
+ // read `['-b', '5']`, `['-t', '--repo', 'owner/name', '5']` and
2789
+ // `['-b', '5']`. In each the first literal character is at index 0 — on
2790
+ // the operator itself — so nothing is stripped.
2791
+ [ROW_LITERAL_BODY_PR, ['-b', '<br>', '5']],
2792
+ [ROW_LITERAL_SUBJECT_REPO, ['-t', '>>', '--repo', 'owner/name', '5']],
2793
+ [ROW_LITERAL_ESCAPED_BODY, ['-b', '<br>', '5']],
2794
+ [ROW_QUOTED_OPERATOR_DATA, ['--squash', '>out.txt']],
2795
+ // …while the UNQUOTED spelling of the same text keeps stripping.
2796
+ ['gh pr merge -b <br> 5', ['-b', '5']],
2797
+ // A QUOTED FILENAME is a redirection all the same, because the operator
2798
+ // characters are bare (round-7 leg, H1/H2): the prefix `>` / `2>` / `<`
2799
+ // / `<<<` / `>>` lies entirely before the token's first literal
2800
+ // character, so the token drops. Every cell here read as DATA under the
2801
+ // round-6 rule — `['--squash', '>merge.log']`, `['5', '>out.txt']`, and
2802
+ // nothing at all for the leading spellings, where the unstripped
2803
+ // operator word sat in front of `gh` and broke the anchor.
2804
+ [ROW_QUOTED_REDIRECT_BRANCH, ['--squash']],
2805
+ [ROW_QUOTED_REDIRECT_ERR, ['--squash']],
2806
+ [ROW_QUOTED_APPEND_BRANCH, ['--squash']],
2807
+ [ROW_QUOTED_VAR_BRANCH, ['--squash']],
2808
+ [ROW_QUOTED_REDIRECT_PR, ['5']],
2809
+ ['>"out.txt" gh pr merge 5', ['5']],
2810
+ ['2>"err.log" gh pr merge 5', ['5']],
2811
+ ['<"in.txt" gh pr merge 5', ['5']],
2812
+ ["<<<'bar' gh pr merge 5", ['5']],
2813
+ // The operator ALONE with a quoted file: the file token goes with it
2814
+ // however it is spelled.
2815
+ ['> "out.txt" gh pr merge 5', ['5']],
2816
+ ['2> "err.log" gh pr merge 5', ['5']],
2817
+ // …and the quoted filename may carry WHITESPACE (round-8 leg, J1): the
2818
+ // operator prefix is what decides, so the fused pattern's filename class
2819
+ // is `[\s\S]+` now. On the fold-4 hook, whose class excluded whitespace,
2820
+ // the leading pair matched neither pattern and projected NOTHING (bash
2821
+ // merges PR 5 in both), and the trailing pair rode the whole word into
2822
+ // argv as `['--squash', '>merge log.txt']` and
2823
+ // `['--squash', '<<<bar baz']`.
2824
+ ['>"out file.txt" gh pr merge 5', ['5']],
2825
+ ['<<<"bar baz" gh pr merge 5', ['5']],
2826
+ [ROW_WS_REDIRECT_BRANCH, ['--squash']],
2827
+ [ROW_WS_HERESTRING_BRANCH, ['--squash']],
2828
+ ['gh pr merge 5 >"out file.txt"', ['5']],
2829
+ // RESIDUE of that rule, locked (round-8 leg, J5): an empty quote pair or
2830
+ // a quoted operator abutting a real one is ONE word here, where bash
2831
+ // passes the quoted part as an argument (`[--squash] []`,
2832
+ // `[--squash] [>]`) and redirects the rest. Text only, and the word this
2833
+ // walk keeps is a target the engine denies.
2834
+ [ROW_EMPTY_QUOTE_ABUT, ['--squash', '>out.txt']],
2835
+ [ROW_QUOTED_OPERATOR_ABUT, ['--squash', '>>out.txt']],
2836
+ // …and the operator prefix is BOUNDED by that same index (fold 6): the
2837
+ // quote or escape sits INSIDE the prefix here, so the operator is the
2838
+ // bare `>` / `<<` / `2<` and the rest is the filename. Each of these
2839
+ // read `[]` on the fold-5 hook — the greedy `>>` / `<<<` / `2<>` ended
2840
+ // past the index, the word was kept, and it stood in front of `gh` —
2841
+ // and the trailing twin read `['--squash', '>>merge.log']`. Bash merges
2842
+ // PR 5 in all six (the `2<` row when the file `>out.txt` exists).
2843
+ ['>">"out.txt gh pr merge 5', ['5']],
2844
+ [">'>'out.txt gh pr merge 5", ['5']],
2845
+ ['>\\>out.txt gh pr merge 5', ['5']],
2846
+ ['>">a "b gh pr merge 5', ['5']],
2847
+ ['<<"<"bar gh pr merge 5', ['5']],
2848
+ ['2<">"out.txt gh pr merge 5', ['5']],
2849
+ [ROW_ABUT_REDIRECT_BRANCH, ['--squash']],
2850
+ // The keep-rows of the bounded prefix: a first literal character at
2851
+ // index 0 leaves no operator prefix before it, so the word is data
2852
+ // whatever its text (`>out.txt` as an argument, `a > b` as a body).
2853
+ [ROW_LITERAL_BODY_SPACED, ['-b', 'a > b', '5']],
2854
+ // The disclosed FALSE FIRE of the bounded prefix, locked both ways
2855
+ // round: an empty quote pair inside the prefix. Bash fails the
2856
+ // redirection on the empty filename and runs NOTHING (exit 1, measured
2857
+ // in an empty directory) while these project a merge — deny direction.
2858
+ // On the fold-5 hook the leading row read `[]` and the trailing one
2859
+ // `['--squash', '>>out.txt']`.
2860
+ [ROW_EMPTY_PAIR_IN_OPERATOR_LEAD, ['5']],
2861
+ [ROW_EMPTY_PAIR_IN_OPERATOR, ['--squash']],
2862
+ // RESIDUE, unreachable rather than claimed: `|` and `&` are the
2863
+ // tokenizer's own separators and end the token before the operator is
2864
+ // whole, so these two never present a redirection to strip.
2865
+ ['>| out.txt gh pr merge 5', null],
2866
+ ['2>&1 gh pr merge 5', null],
2867
+ ];
2868
+ for (const [command, expected] of rows) {
2869
+ const found = ghPrMergeArgvs(command, false);
2870
+ expect(found, JSON.stringify(command)).toEqual(expected === null ? [] : [expected]);
2871
+ }
2872
+ });
2873
+ it('a trailing backtick continues the LINE in ps mode, escapes a newline inside a word, and separates in bash (round-6 leg, G3; round-7 leg, H4)', () => {
2874
+ // The argv exactly, because the payload cannot tell the whole story: the
2875
+ // fold-1 hook read `gh pr merge 5 <backtick><LF>--admin` as
2876
+ // `['5', '<backtick>']` — the same PR 5, with the flag lost to the next
2877
+ // segment — so only this assertion bites on that row. The other two rows
2878
+ // were `['<backtick>']` there, an `unresolvedTarget` payload.
2879
+ const { ghPrMergeArgvs } = wrapperExports();
2880
+ const rows = [
2881
+ [PS_LINE_CONTINUATION_ROWS[0], ['5']],
2882
+ [PS_LINE_CONTINUATION_ROWS[1], ['5', '--admin']],
2883
+ [PS_LINE_CONTINUATION_ROWS[2], ['5']],
2884
+ ];
2885
+ for (const [command, expected] of rows) {
2886
+ expect(ghPrMergeArgvs(command, true), JSON.stringify(command)).toEqual([expected]);
2887
+ }
2888
+ // INSIDE a word the escaped newline lands in the token, so the anchor
2889
+ // reads `merg<LF>e` (or the executable `g<LF>h`) and matches nothing —
2890
+ // exactly what pwsh does with it (round-7 leg, H4). On the fold-3 hook,
2891
+ // which joined at any position, both of these were `[['5']]`.
2892
+ for (const command of PS_CONTINUATION_INSIDE_WORD_ROWS) {
2893
+ expect(ghPrMergeArgvs(command, true), JSON.stringify(command)).toEqual([]);
2894
+ }
2895
+ // The token really carries the newline rather than losing the characters.
2896
+ expect(ghPrMergeArgvs('gh pr merge 5 x`\ny', true)).toEqual([['5', 'x\ny']]);
2897
+ // A trailing backtick with no newline after it is untouched by the arm —
2898
+ // the disclosed false fire, with the backtick riding on as its own token.
2899
+ expect(ghPrMergeArgvs(ROW_PS_TRAILING_BACKTICK, true)).toEqual([['5', '`']]);
2900
+ // …and the arm wants the newline IMMEDIATELY (round-8 leg, J3): put a
2901
+ // space between the backtick and the newline and pwsh escapes that space
2902
+ // instead, running `gh pr merge` with no target, while the separator arm
2903
+ // here hands the backtick on as the target. The false fire, exactly.
2904
+ expect(ghPrMergeArgvs(ROW_PS_BACKTICK_SPACE_NEWLINE, true)).toEqual([['`']]);
2905
+ // Bash: a backtick opens a command substitution whatever follows it, so
2906
+ // the separator stands and the target is the backtick.
2907
+ expect(ghPrMergeArgvs(PS_LINE_CONTINUATION_ROWS[0], false)).toEqual([['`']]);
2908
+ expect(ghPrMergeArgvs(PS_LINE_CONTINUATION_ROWS[1], false)).toEqual([['5', '`']]);
2909
+ // …and bash's own backslash-newline still JOINS inside a word, which is
2910
+ // the difference this arm turns on.
2911
+ expect(ghPrMergeArgvs('gh pr mer\\\nge 5', false)).toEqual([['5']]);
2912
+ });
2913
+ it('the span blanker replaces a body with spaces and keeps every offset (2857 § 1)', () => {
2914
+ const { blankHeredocBodies, findHeredocSpans } = wrapperExports();
2915
+ const command = 'cat <<EOF\ngh pr merge 5\nEOF\ngh pr merge 7';
2916
+ const blanked = blankHeredocBodies(command, false);
2917
+ // Core's shape: same length, the body's characters replaced by spaces, the
2918
+ // operator line and the terminator line untouched.
2919
+ expect(blanked).toHaveLength(command.length);
2920
+ const [span] = findHeredocSpans(command, false);
2921
+ expect(blanked.slice(span.bodyStart, span.bodyEnd)).toBe(' '.repeat(span.bodyEnd - span.bodyStart));
2922
+ expect(blanked.slice(0, span.bodyStart)).toBe(command.slice(0, span.bodyStart));
2923
+ expect(blanked.slice(span.bodyEnd)).toBe(command.slice(span.bodyEnd));
2924
+ });
2925
+ it('isGhExecutable reads the basename after the last / or backslash (§ A)', () => {
2926
+ const { isGhExecutable } = wrapperExports();
2927
+ for (const token of [
2928
+ 'gh',
2929
+ 'gh.exe',
2930
+ 'gh.EXE',
2931
+ // The whole `.exe` basename is case-insensitive (win32 resolves file
2932
+ // names without case); the bare `gh` below stays exact.
2933
+ 'GH.EXE',
2934
+ 'Gh.exe',
2935
+ 'C:\\tools\\GH.EXE',
2936
+ './gh',
2937
+ '../bin/gh',
2938
+ '/usr/local/bin/gh',
2939
+ 'C:\\tools\\gh.exe',
2940
+ '\\\\server\\share\\gh',
2941
+ ]) {
2942
+ expect(isGhExecutable(token), token).toBe(true);
2943
+ }
2944
+ for (const token of [
2945
+ '',
2946
+ 'ghx',
2947
+ 'gh.cmd',
2948
+ 'GH',
2949
+ 'github',
2950
+ 'gh.exe.bak',
2951
+ 'mygh',
2952
+ '$GH',
2953
+ '${GH}',
2954
+ 'C:toolsgh.exe',
2955
+ ]) {
2956
+ expect(isGhExecutable(token), token).toBe(false);
2957
+ }
2958
+ });
2959
+ });
2960
+ // ─── Scanner parity with core (spec `.totem/specs/2857.md` § 3) ────────
2961
+ //
2962
+ // The wrapper's heredoc scanner is a VERBATIM port of core's `findHeredocs`
2963
+ // (`packages/core/src/transport-shield.ts`). A distributed, dependency-free
2964
+ // hook cannot import core — its exports map carries `import` conditions only
2965
+ // and no scanner subpath (mmnto-ai/totem#2851), and the package is not linked
2966
+ // at this monorepo's root — so the cohort lesson for an inlined standalone
2967
+ // utility applies: port it verbatim, anchor BOTH sites, and back the copy with
2968
+ // an executable parity test. This is that test.
2969
+ //
2970
+ // It compares SPANS, not behaviour: a divergence fails here, naming the input,
2971
+ // instead of surfacing later as a heredoc one scanner opens and the other does
2972
+ // not — which is a blanked `gh pr merge` on a following line, a lost advisory
2973
+ // read under PILOT and a bypass under STRICT. The two rows the fix flips from
2974
+ // "never spawns" to projecting (mmnto-ai/totem#2855's locked divergences) are
2975
+ // the proof that this lock BITES: they are exactly the behaviour the ported
2976
+ // arms add.
2977
+ describe('heredoc scanner parity with core (mmnto-ai/totem#2857)', () => {
2978
+ let findHeredocs = null;
2979
+ // The ONE async step: core's scanner is a TypeScript source this suite loads
2980
+ // at run time. Loading it here keeps every row below synchronous, which is
2981
+ // what they are.
2982
+ beforeAll(async () => {
2983
+ findHeredocs = await loadCoreFindHeredocs();
2984
+ });
2985
+ /** Core's spans in the wrapper's shape — same fields, minus the unused `body`. */
2986
+ function coreSpans(command, powershell) {
2987
+ if (findHeredocs === null)
2988
+ throw new Error('core findHeredocs was not loaded');
2989
+ return findHeredocs(command, { powershell }).map((s) => ({
2990
+ delimiter: s.delimiter,
2991
+ quoted: s.quoted,
2992
+ stripTabs: s.stripTabs,
2993
+ unterminated: s.unterminated,
2994
+ bodyStart: s.bodyStart,
2995
+ bodyEnd: s.bodyEnd,
2996
+ }));
2997
+ }
2998
+ /**
2999
+ * A seeded pseudo-random corpus: a 32-bit LCG (Numerical Recipes constants)
3000
+ * with a FIXED seed, so the strings are identical on every machine and every
3001
+ * run and a divergence is reproducible from the seed alone. The alphabet is
3002
+ * the characters the two walks branch on, plus the multi-character tokens
3003
+ * they branch on as a unit.
3004
+ */
3005
+ function fuzzCorpus(count) {
3006
+ const alphabet = [
3007
+ 'a',
3008
+ 'b',
3009
+ '_',
3010
+ '-',
3011
+ '.',
3012
+ ':',
3013
+ '*',
3014
+ '(',
3015
+ ')',
3016
+ '#',
3017
+ '$',
3018
+ '<',
3019
+ '>',
3020
+ "'",
3021
+ '"',
3022
+ '\\',
3023
+ '`',
3024
+ '|',
3025
+ '&',
3026
+ ';',
3027
+ ' ',
3028
+ '\n',
3029
+ '\t',
3030
+ '<<',
3031
+ '<<-',
3032
+ '<<<',
3033
+ '$(',
3034
+ '((',
3035
+ '<#',
3036
+ '#>',
3037
+ 'EOF',
3038
+ ];
3039
+ let state = 20260919 >>> 0;
3040
+ const next = () => {
3041
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
3042
+ return state / 4294967296;
3043
+ };
3044
+ const out = [];
3045
+ for (let i = 0; i < count; i += 1) {
3046
+ const draws = 1 + Math.floor(next() * 40);
3047
+ let s = '';
3048
+ for (let j = 0; j < draws; j += 1) {
3049
+ s += alphabet[Math.floor(next() * alphabet.length)];
3050
+ }
3051
+ out.push(s);
3052
+ }
3053
+ return out;
3054
+ }
3055
+ it('agrees with core over every command in this file, in both modes', () => {
3056
+ const { findHeredocSpans } = wrapperExports();
3057
+ for (const command of PARITY_COMMAND_CORPUS) {
3058
+ for (const powershell of [false, true]) {
3059
+ expect(findHeredocSpans(command, powershell), JSON.stringify({ command, powershell })).toEqual(coreSpans(command, powershell));
3060
+ }
3061
+ }
3062
+ });
3063
+ it('agrees with core over a seeded 3 000-string fuzz corpus, in both modes', () => {
3064
+ const { findHeredocSpans } = wrapperExports();
3065
+ const corpus = fuzzCorpus(3000);
3066
+ expect(corpus).toHaveLength(3000);
3067
+ for (const command of corpus) {
3068
+ for (const powershell of [false, true]) {
3069
+ expect(findHeredocSpans(command, powershell), JSON.stringify({ command, powershell })).toEqual(coreSpans(command, powershell));
3070
+ }
3071
+ }
3072
+ });
3073
+ it('the corpus carries the delimiters the issue names, terminated and not', () => {
3074
+ // The guard on the guard: a corpus that silently lost these rows would
3075
+ // pass the two parity rows above while testing nothing about the bare
3076
+ // delimiter class the port widens.
3077
+ expect(DELIMITER_PARITY_ROWS).toHaveLength(PARITY_DELIMITERS.length * 2);
3078
+ for (const delimiter of PARITY_DELIMITERS) {
3079
+ expect(PARITY_COMMAND_CORPUS.some((c) => c.includes('<<' + delimiter))).toBe(true);
3080
+ // Each one really is a heredoc to CORE — otherwise the row would prove
3081
+ // nothing about the delimiter class.
3082
+ const spans = coreSpans('cat <<' + delimiter + '\nbody\n' + delimiter + '\ngh pr merge 5', false);
3083
+ expect(spans, delimiter).toHaveLength(1);
3084
+ expect(spans[0].delimiter, delimiter).toBe(delimiter);
3085
+ expect(spans[0].unterminated, delimiter).toBe(false);
3086
+ }
3087
+ });
3088
+ });
1430
3089
  //# sourceMappingURL=gate-install.test.js.map