@nanobpm/nano-workforce 0.198.1 → 0.200.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.
@@ -0,0 +1,421 @@
1
+ // Guard for issue #842: agent-prompt result examples must not put free-text JSON in a
2
+ // single-quoted shell string.
3
+ //
4
+ // Agents copy the prompt's `$AGENT_RESULT_FILE` example verbatim. With the old
5
+ // `printf '%s' '{"summary":"…"}' > "$AGENT_RESULT_FILE"` shape, the first apostrophe in the
6
+ // agent's own free-text summary (e.g. "review 123's finding") terminates the single-quoted
7
+ // string. The command then breaks: the nano-coder sandbox rejected it as an "unterminated
8
+ // quote" and the agent had to retry, in a live kimi-k3 review round. A quoted heredoc
9
+ // (`<<'EOF'`) disables all expansion and quoting, so any prose is safe. This guards the
10
+ // whole class across every deployed prompt, not just the one that bit.
11
+ import { test } from "node:test";
12
+ import { assert } from "#test-assert";
13
+ import { readdirSync, readFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+
16
+ // Urban deploys everything under resources/ RECURSIVELY (AGENTS.md "Deploy by convention"), so a
17
+ // nested prompt (resources/**/foo.md) ships just like a top-level one. Scan the whole deployed
18
+ // surface, not just the immediate children of resources/prompts, or a nested prompt could
19
+ // reintroduce the unsafe example past this guard.
20
+ const RESOURCES_DIR = join(import.meta.dirname, "..", "resources");
21
+
22
+ function deployedMarkdownFiles(dir: string): string[] {
23
+ const out: string[] = [];
24
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
25
+ const full = join(dir, entry.name);
26
+ if (entry.isDirectory()) out.push(...deployedMarkdownFiles(full));
27
+ else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
28
+ }
29
+ return out;
30
+ }
31
+
32
+ // The unsafe class is a single-quoted shell string that opens a JSON object (`'{"`) reaching the
33
+ // result file — in ANY redirection form. The retired `printf '…' > "$FILE"` shape is only one
34
+ // instance; `cat > "${AGENT_RESULT_FILE}" <<< '{"…"}'` (herestring, braced var) and
35
+ // `printf '…' | tee "$AGENT_RESULT_FILE"` break identically the moment an apostrophe appears in the
36
+ // free-text JSON. So match the two signals independently (order-free), not the printf layout:
37
+ // (a) a single-quoted JSON opener, and (b) a reference to AGENT_RESULT_FILE (with or without braces).
38
+ // The opener's whitespace class is `\s`, not `[ \t]`: a multiline single-quoted value (`JSON='` on
39
+ // one line, `{"summary":"x"}` on the next) is one shell string, and `unsafeResultBlocks` joins those
40
+ // lines — a horizontal-only class can't cross the inserted newline, so the same apostrophe failure
41
+ // would slip past the guard (review 5387000199). `\s` matches the joined newline too. But `\s` also
42
+ // lets the CLOSING quote of a quoted-heredoc delimiter (`<<'EOF'`) pair with a JSON body line on the
43
+ // next line — the safe shape — so the lookbehind `(?<![A-Za-z0-9_])` requires the `'` to be an
44
+ // OPENING quote (not preceded by a word char), excluding a delimiter's closing quote. JSON also
45
+ // allows whitespace AFTER the opening brace (`'{ "summary":…}'`), which breaks on an apostrophe
46
+ // identically — so the brace is followed by `\s*` too, not `"` directly (review 5387091667).
47
+ const SINGLE_QUOTED_JSON_OPENER = /(?<![A-Za-z0-9_])'\s*\{\s*"/;
48
+ const RESULT_FILE_REF = /\$\{?AGENT_RESULT_FILE\}?/;
49
+ const isUnsafeResultLine = (line: string): boolean =>
50
+ SINGLE_QUOTED_JSON_OPENER.test(line) && RESULT_FILE_REF.test(line);
51
+
52
+ // A single unsafe command need not fit on one physical line: a shell command split with `\`
53
+ // continuations, or a single-quoted JSON assignment on one line whose value is written to the
54
+ // result file on another, has the two signals on different lines and slips past a per-line check.
55
+ // And the assignment and write can be ARBITRARILY separated inside one shell example — three
56
+ // comments between them push the write past any fixed line-distance window, yet the apostrophe
57
+ // still breaks the assignment (review 5387175386). So scope the scan to the unit an agent actually
58
+ // copies and runs — the whole fenced code block — not a fixed window: join continuation lines,
59
+ // then test each fenced block as one unit so a split command / assignment-then-write is caught no
60
+ // matter how far apart the two signals sit inside the block.
61
+ function logicalLines(lines: string[]): string[] {
62
+ const joined: string[] = [];
63
+ let buf = "";
64
+ for (const raw of lines) {
65
+ const line = raw.replace(/\s+$/, "");
66
+ buf = buf ? `${buf}\n${line}` : line;
67
+ if (!line.endsWith("\\")) {
68
+ joined.push(buf);
69
+ buf = "";
70
+ }
71
+ }
72
+ if (buf) joined.push(buf);
73
+ return joined;
74
+ }
75
+ // Extract the fenced ``` code blocks from a markdown example. Only a fenced block is a runnable
76
+ // shell example an agent copies verbatim; prose between blocks never mixes into a command, so a
77
+ // single-quoted JSON opener and a result-file write only form the unsafe shape when they share a
78
+ // block. Scoping to the block (not the whole file) is what keeps a benign single-quoted JSON in
79
+ // one block from pairing with an unrelated `$AGENT_RESULT_FILE` prose mention in another.
80
+ function fencedBlocks(lines: string[]): string[][] {
81
+ const blocks: string[][] = [];
82
+ let cur: string[] | null = null;
83
+ for (const line of lines) {
84
+ if (/^```/.test(line.trim())) {
85
+ if (cur) {
86
+ blocks.push(cur);
87
+ cur = null;
88
+ } else {
89
+ cur = [];
90
+ }
91
+ } else if (cur) {
92
+ cur.push(line);
93
+ }
94
+ }
95
+ if (cur) blocks.push(cur);
96
+ return blocks;
97
+ }
98
+ function unsafeResultBlocks(lines: string[]): number[] {
99
+ const bad: number[] = [];
100
+ for (const block of fencedBlocks(lines)) {
101
+ const text = logicalLines(block).join("\n");
102
+ if (SINGLE_QUOTED_JSON_OPENER.test(text) && RESULT_FILE_REF.test(text)) bad.push(0);
103
+ }
104
+ return bad;
105
+ }
106
+
107
+ // A heredoc is only executable if its terminator is a bare delimiter at column 1. The defect fixed
108
+ // in #843 was an INDENTED `EOF` (list-indented markdown) that the shell never recognises, so the
109
+ // heredoc runs to EOF-of-file and the result file is garbage. Validate each deployed heredoc as a
110
+ // complete block: every `<<'TAG'` opener must be closed by a line that is exactly `TAG` at column 1.
111
+ // An opener is a real shell redirect, so ignore backtick-quoted inline-code mentions (`<<'EOF'`) and
112
+ // prose references to the syntax — only a `<<'TAG'` that is NOT inside backticks opens a heredoc.
113
+ // Capture whether the opener used `<<-` (strip-leading-tabs): shell `<<-` deliberately permits a
114
+ // TAB-indented terminator, so the dash flag must be preserved per pending heredoc and the
115
+ // terminator check strips leading tabs only for a `<<-` opener (review 5387476399, previously-missed).
116
+ // The `<<` must NOT be preceded by another `<`: a here-STRING `cat <<<'EOF'` is not a heredoc at
117
+ // all (it feeds the literal text `EOF` to the command's stdin), so without the `(?<!<)` lookbehind
118
+ // the regex matches the here-string's final two `<` and reports an unterminated heredoc where none
119
+ // exists (review 5387687539).
120
+ const HEREDOC_OPENER = /(?<!<)<<(-?)\s*'([A-Za-z_][A-Za-z0-9_]*)'/;
121
+ function heredocOpeners(line: string): { tag: string; dash: boolean }[] {
122
+ // Strip inline-code spans (`...`) so a prose mention of `<<'EOF'` is not mistaken for an opener.
123
+ const code = line.replace(/`[^`]*`/g, "");
124
+ if (code.trimStart().startsWith("#")) return []; // a commented-out example is not a real opener
125
+ const tags: { tag: string; dash: boolean }[] = [];
126
+ for (const m of code.matchAll(new RegExp(HEREDOC_OPENER.source, "g")))
127
+ tags.push({ tag: m[2], dash: m[1] === "-" });
128
+ return tags;
129
+ }
130
+ // Model the pending heredocs as an ordered QUEUE of expected terminators. One command line can
131
+ // stack several openers (`cat <<'A' <<'B'`), and the shell reads each body in turn, so EVERY opener
132
+ // needs its own later column-1 terminator. A single terminator line therefore closes only the FRONT
133
+ // pending heredoc — it must NOT be credited to every same-line opener that happens to share the
134
+ // delimiter: `cat <<'EOF' <<'EOF'` closed by a single `EOF` still leaves a second heredoc open, so
135
+ // it is malformed (review 5387296036).
136
+ //
137
+ // While a heredoc is pending its body is LITERAL shell input, so a body line is never a new opener:
138
+ // once `cat <<'OUTER'` opens, a body line containing the text `<<'INNER'` is data the command reads,
139
+ // not a redirection, and must not enqueue an `INNER` terminator (review 5387415767). So when the
140
+ // queue is non-empty we only test whether the line is the FRONT terminator; if it is, that heredoc
141
+ // closes, and either way we skip opener discovery for the line. Only a line scanned while NO heredoc
142
+ // is pending can contribute new openers. Every opener still pending at end-of-input is an
143
+ // unterminated heredoc.
144
+ //
145
+ // Two refinements:
146
+ // - A `<<-` opener's terminator may be TAB-indented (shell strips leading tabs), so the terminator
147
+ // test strips leading tabs only when that pending opener used `<<-`; a plain `<<` still requires
148
+ // the bare delimiter at column 1.
149
+ // - A heredoc body does not begin until a backslash-CONTINUED command is complete: `cat <<'A' \`
150
+ // followed by `<<'B'` is one command stacking two heredocs, so the second line is more command,
151
+ // not body, and its opener must still be discovered (review 5387476399). Continuation state must
152
+ // be tracked ONLY across the command portion — as an explicit flag carried forward, NOT recomputed
153
+ // from the previous physical line each iteration (review 5387541694). Recomputing it per line had
154
+ // two symmetric defects: (a) a terminator was tested BEFORE continuation, so `cat <<'A' \` then a
155
+ // bare `A` credited that `A` as the terminator even though it is a command argument (real bash:
156
+ // `cat: A: No such file or directory`) and the heredoc is unterminated; and (b) a BODY line ending
157
+ // in a backslash wrongly re-entered command mode, letting the next literal body line (e.g. text
158
+ // `<<'INNER'`) be parsed as a new opener. With the flag, `continued` is updated only while in
159
+ // command mode: once the command completes (a command line not ending in `\`), the body is literal
160
+ // and a trailing backslash inside it can never reopen command mode.
161
+ function malformedHeredocs(lines: string[]): string[] {
162
+ const pending: { tag: string; dash: boolean; line: number }[] = [];
163
+ const isTerminator = (line: string, open: { tag: string; dash: boolean }) =>
164
+ open.dash ? line.replace(/^\t+/, "") === open.tag : line === open.tag;
165
+ // `continued` === we are still inside the (possibly multi-line, backslash-continued) COMMAND that
166
+ // opened the pending heredocs — its body has not begun yet. While command mode is active we discover
167
+ // openers and never test terminators; once it ends, pending bodies are literal until their terminators.
168
+ let continued = false;
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const inBody = pending.length > 0 && !continued;
171
+ if (inBody) {
172
+ // A pending body is literal input: the only thing that matters is whether this line closes the
173
+ // FRONT heredoc. It never opens a new one and never re-enters command mode.
174
+ if (isTerminator(lines[i], pending[0])) pending.shift();
175
+ continue;
176
+ }
177
+ for (const open of heredocOpeners(lines[i])) pending.push({ ...open, line: i });
178
+ continued = lines[i].replace(/\s+$/, "").endsWith("\\");
179
+ }
180
+ return pending.map(
181
+ (open) => `line ${open.line + 1}: heredoc <<'${open.tag}' has no column-1 '${open.tag}' terminator`,
182
+ );
183
+ }
184
+ // Validate a whole Markdown file one fenced block at a time. A runnable heredoc example lives inside
185
+ // a single ``` fence; scanning the whole file at once lets an unterminated opener borrow a bare
186
+ // delimiter from later prose or another fence (review 5387415767, previously-missed). `malformedHeredocs`
187
+ // itself validates ONE runnable unit (its `lines` are the block), so the cross-fence isolation lives
188
+ // here in the caller: feed each fenced block separately so a terminator can never cross a fence.
189
+ function malformedHeredocsInFile(lines: string[]): string[] {
190
+ const bad: string[] = [];
191
+ for (const block of fencedBlocks(lines)) bad.push(...malformedHeredocs(block));
192
+ return bad;
193
+ }
194
+
195
+ test("no agent prompt writes $AGENT_RESULT_FILE from a single-quoted JSON string (#842)", () => {
196
+ const offenders: string[] = [];
197
+ for (const file of deployedMarkdownFiles(RESOURCES_DIR)) {
198
+ const rel = file.slice(RESOURCES_DIR.length + 1);
199
+ const lines = readFileSync(file, "utf8").split("\n");
200
+ for (const i of unsafeResultBlocks(lines)) offenders.push(`${rel}:${i + 1}`);
201
+ }
202
+ assert(
203
+ offenders.length === 0,
204
+ `use a quoted heredoc (cat > "$AGENT_RESULT_FILE" <<'EOF') instead of a single-quoted JSON string: ${offenders.join(", ")}`,
205
+ );
206
+ });
207
+
208
+ test("every deployed heredoc is a complete, executable block with a column-1 terminator (#843)", () => {
209
+ const problems: string[] = [];
210
+ for (const file of deployedMarkdownFiles(RESOURCES_DIR)) {
211
+ const rel = file.slice(RESOURCES_DIR.length + 1);
212
+ const lines = readFileSync(file, "utf8").split("\n");
213
+ for (const p of malformedHeredocsInFile(lines)) problems.push(`${rel}: ${p}`);
214
+ }
215
+ assert(
216
+ problems.length === 0,
217
+ `every heredoc terminator must be the bare delimiter at column 1 so the example runs verbatim:\n${problems.join("\n")}`,
218
+ );
219
+ });
220
+
221
+ test("the guard flags every unsafe result-file shape and accepts the heredoc shape", () => {
222
+ // The printf redirection that bit, plus the equivalents a `> "$AGENT_RESULT_FILE"`-only check missed:
223
+ assert(
224
+ isUnsafeResultLine(`printf '%s' '{"status":"addressed","summary":"x"}' > "$AGENT_RESULT_FILE"`),
225
+ "retired printf shape must be flagged",
226
+ );
227
+ assert(
228
+ isUnsafeResultLine(`printf '%s' ' {"summary":"x"}' > "$AGENT_RESULT_FILE"`),
229
+ "single-quoted JSON with leading whitespace before { must still be flagged",
230
+ );
231
+ assert(
232
+ isUnsafeResultLine(`printf '%s' '{ "summary":"review 123's finding"}' > "$AGENT_RESULT_FILE"`),
233
+ "single-quoted JSON with whitespace after { must still be flagged (review 5387091667)",
234
+ );
235
+ assert(
236
+ isUnsafeResultLine(`cat > "\${AGENT_RESULT_FILE}" <<< '{"summary":"x"}'`),
237
+ "herestring into a braced result var must be flagged",
238
+ );
239
+ assert(
240
+ isUnsafeResultLine(`printf '%s' '{"summary":"x"}' | tee "$AGENT_RESULT_FILE"`),
241
+ "tee pipe of single-quoted JSON must be flagged",
242
+ );
243
+ // The safe heredoc shape, and its dedented/body lines, must all pass:
244
+ assert(!isUnsafeResultLine(`cat > "$AGENT_RESULT_FILE" <<'EOF'`), "heredoc opener must pass");
245
+ assert(!isUnsafeResultLine(`{"status":"addressed","summary":"x"}`), "heredoc body line must pass");
246
+ assert(!isUnsafeResultLine(`EOF`), "heredoc terminator must pass");
247
+ });
248
+
249
+ test("the guard flags unsafe shapes split across lines (continuations and assignment-then-write)", () => {
250
+ // A single-quoted JSON and the result-file write on DIFFERENT physical lines must still be caught.
251
+ // Each fixture is a fenced ``` block — the unit an agent copies and runs.
252
+ const continuation = ["```sh", `printf '%s' \\`, `'{"summary":"x"}' \\`, `> "$AGENT_RESULT_FILE"`, "```"];
253
+ assert(unsafeResultBlocks(continuation).length > 0, "backslash-continued unsafe command must be flagged");
254
+ const assignThenWrite = ["```sh", `JSON='{"summary":"x"}'`, `printf '%s' "$JSON" > "$AGENT_RESULT_FILE"`, "```"];
255
+ assert(unsafeResultBlocks(assignThenWrite).length > 0, "single-quoted JSON assigned then written must be flagged");
256
+ // A MULTILINE single-quoted value — `JSON='` opens on one line and the JSON object starts on the
257
+ // next — is one shell string. The joined block inserts a newline between `'` and `{"`, which a
258
+ // horizontal-only whitespace class cannot cross (review 5387000199): this shape must be flagged.
259
+ const multilineSingleQuoted = ["```sh", `JSON='`, `{"summary":"x"}'`, `printf '%s' "$JSON" > "$AGENT_RESULT_FILE"`, "```"];
260
+ assert(
261
+ unsafeResultBlocks(multilineSingleQuoted).length > 0,
262
+ "multiline single-quoted JSON value reaching the result file must be flagged",
263
+ );
264
+ // The safe heredoc shape spans lines but has no single-quoted JSON opener, so it must pass.
265
+ const safeHeredoc = ["```sh", `cat > "$AGENT_RESULT_FILE" <<'EOF'`, `{"summary":"x"}`, `EOF`, "```"];
266
+ assert(unsafeResultBlocks(safeHeredoc).length === 0, "safe heredoc block must pass");
267
+ // A benign single-quoted JSON in one fenced block must NOT pair with a `$AGENT_RESULT_FILE` prose
268
+ // mention in a DIFFERENT block — scoping to the block (not the whole file) prevents that false
269
+ // positive.
270
+ const separateBlocks = [
271
+ "```sh",
272
+ `echo '{"status":"ok"}'`,
273
+ "```",
274
+ "Write the result to `$AGENT_RESULT_FILE` as shown above.",
275
+ ];
276
+ assert(
277
+ unsafeResultBlocks(separateBlocks).length === 0,
278
+ "a benign single-quoted JSON in one block and a result-file mention in another must not be flagged",
279
+ );
280
+ // An assignment and its write can be arbitrarily separated inside one shell example. Three
281
+ // comments between them push the write past a fixed four-line window, yet the apostrophe still
282
+ // breaks the assignment — so the scan is scoped to the whole fenced code block, not a fixed
283
+ // line distance (review 5387175386).
284
+ const beyondWindow = [
285
+ "```sh",
286
+ `JSON='{"summary":"review 123's finding"}'`,
287
+ `# comment one`,
288
+ `# comment two`,
289
+ `# comment three`,
290
+ `printf '%s' "$JSON" > "$AGENT_RESULT_FILE"`,
291
+ "```",
292
+ ];
293
+ assert(
294
+ unsafeResultBlocks(beyondWindow).length > 0,
295
+ "single-quoted JSON assigned then written beyond a fixed four-line window must be flagged",
296
+ );
297
+ });
298
+
299
+ test("the heredoc block validator flags an indented terminator and accepts a column-1 one", () => {
300
+ // The concrete defect fixed in #843: an indented `EOF` is not a terminator.
301
+ assert(
302
+ malformedHeredocs([`cat > "$F" <<'EOF'`, `{"a":1}`, ` EOF`]).length > 0,
303
+ "indented terminator must be flagged as an unterminated heredoc",
304
+ );
305
+ assert(
306
+ malformedHeredocs([`cat > "$F" <<'EOF'`, `{"a":1}`, `EOF`]).length === 0,
307
+ "column-1 terminator must pass",
308
+ );
309
+ // A commented-out example opener is not a real heredoc and must not be flagged.
310
+ assert(
311
+ malformedHeredocs([` # BODY=$(cat <<'EOF'`, ` # EOF`]).length === 0,
312
+ "commented-out example must be ignored",
313
+ );
314
+ // A prose / inline-code mention of `<<'EOF'` is not an opener and must not be flagged.
315
+ assert(
316
+ malformedHeredocs([" Use the **quoted heredoc** shown above (`<<'EOF'`), never a single-quoted"]).length === 0,
317
+ "inline-code mention of <<'EOF' in prose must not be treated as an opener",
318
+ );
319
+ // A heredoc whose terminator is INDENTED is not terminated at that line, so the shell keeps
320
+ // reading body until a column-1 delimiter. Under correct shell semantics (verified against bash),
321
+ // a later `cat <<'EOF'` line INSIDE that pending body is literal text, not a second opener — so
322
+ // `cat <<'EOF'` / ` EOF` / `cat <<'EOF'` / `body` / `EOF` is ONE well-formed heredoc closed by the
323
+ // final column-1 `EOF`. The "reused delimiter lends its terminator" hazard this fixture guarded in
324
+ // round 8 is real only when the two blocks are SEPARATE runnable units — i.e. in different fences —
325
+ // which is exactly the cross-fence case `malformedHeredocsInFile` now isolates (review 5387415767).
326
+ // Within one pending body there is no second opener to mis-credit, so this must PASS.
327
+ assert(
328
+ malformedHeredocs([`cat <<'EOF'`, ` EOF`, `cat <<'EOF'`, `body`, `EOF`]).length === 0,
329
+ "a later same-delimiter line inside a pending body is literal text, not a borrowed terminator",
330
+ );
331
+ // The cross-fence borrow IS malformed: an unterminated opener in one fence must not be closed by a
332
+ // bare delimiter in later prose or another fence. This is the per-fence isolation fix.
333
+ assert(
334
+ malformedHeredocsInFile(["```sh", `cat <<'EOF'`, "```", "trailing prose", "EOF"]).length > 0,
335
+ "an unterminated fence must not borrow a terminator from later prose (review 5387415767)",
336
+ );
337
+ // Two correctly-terminated blocks reusing the same delimiter must still pass.
338
+ assert(
339
+ malformedHeredocs([`cat <<'EOF'`, `a`, `EOF`, `cat <<'EOF'`, `b`, `EOF`]).length === 0,
340
+ "two well-formed blocks reusing one delimiter must pass",
341
+ );
342
+ // Several heredocs STACKED on one line (`cat <<'EOF' <<'EOF'`) each need their own terminator: the
343
+ // shell reads each body in turn, so one `EOF` closes only the first and leaves the second open.
344
+ // A single terminator must not be credited to every same-line opener sharing the delimiter
345
+ // (review 5387296036).
346
+ assert(
347
+ malformedHeredocs([`cat <<'EOF' <<'EOF'`, `body`, `EOF`]).length > 0,
348
+ "one terminator must not close two same-line heredocs sharing a delimiter",
349
+ );
350
+ // The same stacked pair with BOTH terminators present is well-formed and must pass.
351
+ assert(
352
+ malformedHeredocs([`cat <<'EOF' <<'EOF'`, `a`, `EOF`, `b`, `EOF`]).length === 0,
353
+ "two same-line heredocs each with their own column-1 terminator must pass",
354
+ );
355
+ // A pending heredoc's body is LITERAL shell input: a body line containing the text `<<'INNER'` is
356
+ // data the command reads, not a redirection, so it must NOT enqueue an `INNER` terminator. The
357
+ // block is one well-formed heredoc closed by `OUTER` (review 5387415767).
358
+ assert(
359
+ malformedHeredocs([`cat <<'OUTER'`, `this body mentions <<'INNER' as literal text`, `OUTER`]).length === 0,
360
+ "a body line containing a heredoc-opener-looking text must not be treated as a new opener",
361
+ );
362
+ // The same holds when heredocs are stacked: once `A` is pending, a body line with `<<'C'` is
363
+ // literal until `A` (then `B`) terminate in turn.
364
+ assert(
365
+ malformedHeredocs([`cat <<'A' <<'B'`, `a`, `A`, `body with <<'C' inside`, `B`]).length === 0,
366
+ "a stacked heredoc body containing opener-like text must still pass",
367
+ );
368
+ // A heredoc body does not begin until a backslash-CONTINUED command completes: `cat <<'A' \`
369
+ // followed by `<<'B'` is ONE command stacking two heredocs, so the second line is more command,
370
+ // not body, and its opener must be discovered. Omitting the `B` terminator is therefore malformed
371
+ // (review 5387476399).
372
+ assert(
373
+ malformedHeredocs([`cat <<'A' \\`, `<<'B'`, `body-A`, `A`]).length > 0,
374
+ "a backslash-continued second opener must be discovered, so its omitted terminator is flagged",
375
+ );
376
+ // The same split stacked-heredoc command with BOTH terminators present is well-formed and passes.
377
+ assert(
378
+ malformedHeredocs([`cat <<'A' \\`, `<<'B'`, `body-A`, `A`, `body-B`, `B`]).length === 0,
379
+ "a backslash-continued stacked command with both terminators must pass",
380
+ );
381
+ // A `<<-` opener's terminator may be TAB-indented (shell strips leading tabs), so a tab-indented
382
+ // `EOF` closes a `<<-'EOF'` heredoc (review 5387476399, previously-missed).
383
+ assert(
384
+ malformedHeredocs([`cat <<-'EOF'`, `\tbody`, `\tEOF`]).length === 0,
385
+ "a <<- heredoc must accept a tab-indented terminator",
386
+ );
387
+ // A plain `<<` opener still requires the bare delimiter at column 1: a tab-indented `EOF` does NOT
388
+ // terminate it, so the heredoc runs to end-of-input and is malformed.
389
+ assert(
390
+ malformedHeredocs([`cat <<'EOF'`, `\tbody`, `\tEOF`]).length > 0,
391
+ "a plain << heredoc must still reject a tab-indented terminator",
392
+ );
393
+ // Continuation state is tracked only across the COMMAND, not recomputed per line (review 5387541694).
394
+ // (F1) A terminator is never credited while the command is still being continued: `cat <<'A' \`
395
+ // followed by a bare `A` is `cat <<'A' A` — the `A` is a command ARGUMENT (real bash: `cat: A: No
396
+ // such file or directory`), not the terminator, so the heredoc is unterminated and must be flagged.
397
+ assert(
398
+ malformedHeredocs([`cat <<'A' \\`, `A`]).length > 0,
399
+ "a bare delimiter on a backslash-continued command line is an argument, not a terminator",
400
+ );
401
+ // (F2) A trailing backslash inside a heredoc BODY must NOT re-enter command mode: once `cat <<'OUTER'`
402
+ // opens, a body line ending in `\` is still literal data, so the following `<<'INNER'` is body text,
403
+ // not a new opener — the block is well-formed with just the one `OUTER` terminator.
404
+ assert(
405
+ malformedHeredocs([`cat <<'OUTER'`, `some text \\`, `<<'INNER'`, `OUTER`]).length === 0,
406
+ "a backslash at the end of a body line must not reopen command mode and parse the next body line as an opener",
407
+ );
408
+ // A here-STRING `cat <<<'EOF'` is NOT a heredoc: it feeds the literal text `EOF` to the command's
409
+ // stdin (real bash prints `EOF`, exit 0 — no terminator is ever expected). The opener regex must
410
+ // not match the here-string's final two `<`, so no `EOF` terminator is required (review 5387687539).
411
+ assert(
412
+ malformedHeredocs([`cat <<<'EOF'`]).length === 0,
413
+ "a here-string `<<<'EOF'` must not be reported as an unterminated heredoc",
414
+ );
415
+ // A real `<<` heredoc still matches even when it directly follows other text on the line, and a
416
+ // here-string's leading `<<<` must not swallow a genuine `<<` opener later on the same line.
417
+ assert(
418
+ malformedHeredocs([`cat <<<'X' && cat <<'EOF'`, `body`, `EOF`]).length === 0,
419
+ "a real heredoc later on a line that also contains a here-string must still be validated",
420
+ );
421
+ });
@@ -133,7 +133,8 @@ test("convergence-loop golden has arbitrary-graph features the structured builde
133
133
  // merging SIX back-edges directly is the arbitrary-graph shape the structured builder cannot emit —
134
134
  // the feature this asserts, now on `capture-head`.
135
135
  assertEquals(between("capture-head", "serviceTask", "incoming"), 6, "capture-head should merge 6 flows on the task itself");
136
- assertEquals(between("review-round", "serviceTask", "incoming"), 1, "review-round now takes the single f_capture in-edge");
136
+ // #844: review-round also merges the bounded adversarial-findings back-edge (f_advFindings).
137
+ assertEquals(between("review-round", "serviceTask", "incoming"), 2, "review-round takes f_capture + the f_advFindings back-edge");
137
138
  // (b) a single exclusive gateway forks FOUR heterogeneous-condition out-edges.
138
139
  assertEquals(between("gw-status", "exclusiveGateway", "outgoing"), 4, "gw-status should be a 4-way exclusive gateway");
139
140
  // (c) a single exclusive gateway is at once a 6-way merge and a 2-way split.