@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.
- package/dist/commands/gate-install.test.js +1763 -104
- package/dist/commands/gate-install.test.js.map +1 -1
- package/dist/commands/init-templates.d.ts +3 -3
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +1032 -255
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +53 -0
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/mail-degraded-e4.test.js +41 -26
- package/dist/commands/mail-degraded-e4.test.js.map +1 -1
- package/dist/commands/mail.test.js +18 -10
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/shield.d.ts +7 -3
- package/dist/commands/shield.d.ts.map +1 -1
- package/dist/commands/shield.js +7 -3
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/signoff-table-sync.test.js +42 -14
- package/dist/commands/signoff-table-sync.test.js.map +1 -1
- package/dist/commands/sync.d.ts +11 -4
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +20 -10
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/sync.test.js +5 -3
- package/dist/commands/sync.test.js.map +1 -1
- package/package.json +2 -2
|
@@ -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 {
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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 arm — each 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
|
|
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
|
-
//
|
|
998
|
-
//
|
|
999
|
-
//
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|