@nanobpm/nano-workforce 0.198.0 → 0.199.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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.199.0](https://github.com/nanobpm/nano-workforce/compare/v0.198.1...v0.199.0) (2026-10-02)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **review-round:** fix the bug class + self-review before push; heredoc result examples ([#843](https://github.com/nanobpm/nano-workforce/issues/843)) ([0b2f26f](https://github.com/nanobpm/nano-workforce/commit/0b2f26f817f15191ef530075d34f6131c99e43cb)), closes [#842](https://github.com/nanobpm/nano-workforce/issues/842)
|
|
6
|
+
|
|
7
|
+
## [0.198.1](https://github.com/nanobpm/nano-workforce/compare/v0.198.0...v0.198.1) (2026-10-02)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **deps:** update dependency @nanobpm/agentic to ^0.15.0 ([#848](https://github.com/nanobpm/nano-workforce/issues/848)) ([26f731a](https://github.com/nanobpm/nano-workforce/commit/26f731a9d87a50a9a4852dc7dd5bc8b3e61c72c4))
|
|
12
|
+
|
|
1
13
|
## [0.198.0](https://github.com/nanobpm/nano-workforce/compare/v0.197.0...v0.198.0) (2026-10-02)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.199.0",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"lint:fix": "biome check --write app operations workers pages components scripts e2e main.ts"
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
|
-
"@nanobpm/agentic": "^0.
|
|
67
|
+
"@nanobpm/agentic": "^0.15.0",
|
|
68
68
|
"@nanobpm/urban": "^0.95.0",
|
|
69
69
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
70
70
|
},
|
|
@@ -120,14 +120,22 @@ machine-readable result one of two ways:
|
|
|
120
120
|
harness sets for you). The object's keys become process variables. Examples:
|
|
121
121
|
|
|
122
122
|
```sh
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
# pushed a fix you believe turns the failing checks green:
|
|
124
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
125
|
+
{"status":"fixed","pushed":true,"summary":"Fixed the flaky timeout in auth.test.ts and pushed"}
|
|
126
|
+
EOF
|
|
127
|
+
# nothing to fix — the failing checks are stale/transient (CANCELLED superseded on the same head SHA, head already green); just re-attempt the merge:
|
|
128
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
129
|
+
{"status":"reattempt","pushed":false,"summary":"Failing checks are CANCELLED runs superseded by a green run on the same head SHA — nothing to fix, re-queue the merge"}
|
|
130
|
+
EOF
|
|
131
|
+
# ordering constraint — must wait for another PR to land first:
|
|
132
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
133
|
+
{"status":"waiting-on-pr","pushed":false,"summary":"Blocked by the linked-issue gate","dependsOn":"owner/repo#123"}
|
|
134
|
+
EOF
|
|
135
|
+
# genuinely stuck — a human must decide:
|
|
136
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
137
|
+
{"status":"blocked","pushed":false,"summary":"CI needs an NPM_TOKEN secret I cannot set","question":"Add the NPM_TOKEN repo secret, then answer to rerun."}
|
|
138
|
+
EOF
|
|
131
139
|
```
|
|
132
140
|
|
|
133
141
|
Write this file **once**, at the very end, with your final result. Keep it a flat
|
|
@@ -113,12 +113,18 @@ machine-readable result one of two ways:
|
|
|
113
113
|
harness sets for you). The object's keys become process variables. Examples:
|
|
114
114
|
|
|
115
115
|
```sh
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
116
|
+
# branch tip now contains the latest base (you pushed a resolved rebase, or it was already up to date):
|
|
117
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
118
|
+
{"status":"rebased","summary":"Rebased onto main, resolved 2 conflicts in router.ts, pushed"}
|
|
119
|
+
EOF
|
|
120
|
+
# ordering constraint — must wait for another PR to land first:
|
|
121
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
122
|
+
{"status":"waiting-on-pr","summary":"Stacked on the base PR that has not merged","dependsOn":"owner/repo#123"}
|
|
123
|
+
EOF
|
|
124
|
+
# genuine semantic conflict — a human must decide which behaviour wins:
|
|
125
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
126
|
+
{"status":"blocked","summary":"main and this branch both rewrote retry() incompatibly","question":"Should retries stay capped at 3 (main) or become unbounded (this PR)?"}
|
|
127
|
+
EOF
|
|
122
128
|
```
|
|
123
129
|
|
|
124
130
|
Write this file **once**, at the very end, with your final result. Keep it a flat
|
|
@@ -90,13 +90,60 @@ Because several agents may run on the same host at once:
|
|
|
90
90
|
*nitpick*); otherwise **decline it explicitly with a one-line rationale in your
|
|
91
91
|
`summary`** (e.g. "declined suppressed advisory X — input already validated
|
|
92
92
|
upstream at Y"). Never silently drop one.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
Treat a **"Previously missed"** advisory as evidence that a whole *category* of issue
|
|
94
|
+
(e.g. unvalidated input, terminal-width overflow, an unhandled error path) went
|
|
95
|
+
unchecked in that area. Sweep **every file you touch** for that category this round,
|
|
96
|
+
rather than fixing only the cited line and waiting for the next review to cite the
|
|
97
|
+
neighbour.
|
|
98
|
+
3. **Act — fix the class, not the instance.** Each review round costs a full GitHub
|
|
99
|
+
re-review cycle, so a fix that closes only the cited occurrence just buys another
|
|
100
|
+
round when the reviewer finds its sibling. For every *fix*:
|
|
101
|
+
- **Name the bug class** the finding is an instance of (e.g. "fail-open detector:
|
|
102
|
+
an unrecognised shape is allowed through", "unsanitised text reaches the
|
|
103
|
+
terminal", "unbounded growth").
|
|
104
|
+
- **Sweep for the class.** Search the changed code and its siblings (same file,
|
|
105
|
+
same pattern, same helper's other call sites) for every other occurrence, and fix
|
|
106
|
+
them in this same commit. Prefer a structural fix that removes the whole class
|
|
107
|
+
(e.g. fail closed by default, one validating constructor) over patching the cited
|
|
108
|
+
case.
|
|
109
|
+
- **Test the class.** Add a red-first regression test per variant you found, not
|
|
110
|
+
only the one cited.
|
|
111
|
+
|
|
112
|
+
Make the code changes for all fixes + nitpicks in your workspace (`cwd`)
|
|
113
|
+
in one coherent, signed-off commit (`git commit -s`). If the branch has drifted
|
|
97
114
|
behind its base and you need to **rebase / resolve a merge conflict** to keep it
|
|
98
|
-
mergeable, that
|
|
99
|
-
|
|
115
|
+
mergeable, do that rebase **now, before validating** — never after the self-review
|
|
116
|
+
pass below. Then run the repo's build/test/lint locally.
|
|
117
|
+
|
|
118
|
+
**Before you push, do one adversarial self-review pass over your own diff.**
|
|
119
|
+
Diff against the PR's **current base**, not `origin/<head-branch>`: after a
|
|
120
|
+
permitted rebase, `origin/<head-branch>` still names the pre-rebase remote
|
|
121
|
+
history, so the three-dot merge base can fall back to the old base and pull
|
|
122
|
+
unrelated upstream changes into the pass. Resolve and fetch the base fresh,
|
|
123
|
+
then diff it against `HEAD` — correct before and after any rebase:
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
BASE=$(gh pr view --json baseRefName --jq .baseRefName) # the PR's current base branch
|
|
127
|
+
git fetch origin "$BASE"
|
|
128
|
+
git diff FETCH_HEAD...HEAD # the diff the reviewer actually sees
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Read it as the reviewer will: what input,
|
|
132
|
+
edge case, error path, or bypass would a hostile reviewer cite next? Fix what you
|
|
133
|
+
find in the same commit. **If this pass edits the diff, re-run the repo's
|
|
134
|
+
build/test/lint before pushing** — otherwise the self-review fix ships unvalidated,
|
|
135
|
+
since the checks above ran against the pre-self-review code. **The same rule covers
|
|
136
|
+
any change after the pass:** if you rebase, resolve a conflict, or otherwise alter
|
|
137
|
+
the diff after this point, re-run build/test/lint *and* redo this self-review before
|
|
138
|
+
pushing — a late rebase must never ship unvalidated. Do only **one** self-review
|
|
139
|
+
pass per state of the diff: it exists to catch the obvious next finding, not to
|
|
140
|
+
block the round.
|
|
141
|
+
|
|
142
|
+
Push to the PR's head branch (the branch
|
|
143
|
+
you are already on) — do not open a new branch or PR. A rebase/force-push
|
|
144
|
+
(`--force-with-lease`) is allowed when it is needed to keep the branch mergeable,
|
|
145
|
+
but only **before** the validation + self-review above (or followed by re-running
|
|
146
|
+
both). Any push this round — including a rebase/force-push with
|
|
100
147
|
no reviewer comments to act on — is an **`addressed`** round (see the return table).
|
|
101
148
|
4. **Reply in-thread** to each comment you addressed or pushed back on, one reply
|
|
102
149
|
per comment, so the trail lives on the PR.
|
|
@@ -142,24 +189,24 @@ Because several agents may run on the same host at once:
|
|
|
142
189
|
leading row summary before it (e.g. `nano-ack: a.json :: Adds X; A is wrong`). Example:
|
|
143
190
|
|
|
144
191
|
```sh
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
192
|
+
# Post the ack thread (pick any changed line in the diff for path/line). Use the PR's real HEAD
|
|
193
|
+
# SHA as commit_id — `git rev-parse HEAD` can drift from the PR head; ask GitHub:
|
|
194
|
+
CID=$(gh api repos/OWNER/REPO/pulls/PR --jq .head.sha)
|
|
195
|
+
# Build the body via a QUOTED heredoc so the verbatim advisory prose is never re-interpreted by
|
|
196
|
+
# the shell — a single-quoted `-f body='...'` breaks the moment the prose contains a `'` (e.g.
|
|
197
|
+
# "doesn't handle ..."), and a double-quoted one breaks on `$`/backticks. `<<'EOF'` (quoted
|
|
198
|
+
# delimiter) disables ALL expansion, so any advisory text is safe:
|
|
199
|
+
BODY=$(cat <<'EOF'
|
|
200
|
+
Applied. nano-ack: <path> :: <verbatim advisory text>
|
|
201
|
+
EOF
|
|
202
|
+
) # to DECLINE instead, build the body the same quoted-heredoc way (never a single-quoted
|
|
203
|
+
# `-f body='...'`, which breaks the moment the reason or advisory prose contains a `'`):
|
|
204
|
+
# BODY=$(cat <<'EOF'
|
|
205
|
+
# Declined, false positive — <reason>. nano-ack: <path> :: <verbatim advisory text>
|
|
206
|
+
# EOF
|
|
207
|
+
# )
|
|
208
|
+
gh api repos/OWNER/REPO/pulls/PR/comments -f commit_id="$CID" -f path="PATH" -F line=LINE -f side=RIGHT -f body="$BODY"
|
|
209
|
+
# Then resolve it exactly like any other thread (map its databaseId -> thread node id -> resolveReviewThread).
|
|
163
210
|
```
|
|
164
211
|
Only the `nano-ack: <path> :: <text>` (prose-keyed) form is honoured. A bare
|
|
165
212
|
`nano-ack: <path>:<line>` marker is **not** an acknowledgement: keyed only on
|
|
@@ -258,9 +305,15 @@ default, and you waste a round. So emit a machine-readable result one of two way
|
|
|
258
305
|
round that needs a human decision:
|
|
259
306
|
|
|
260
307
|
```sh
|
|
261
|
-
|
|
308
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
309
|
+
{"status":"needs_input","summary":"Resolved 3 nits; blocked on API shape","question":"Should getUser() throw or return null when the user is absent?"}
|
|
310
|
+
EOF
|
|
262
311
|
```
|
|
263
312
|
|
|
313
|
+
Use the **quoted heredoc** shown above (`<<'EOF'`), never a single-quoted
|
|
314
|
+
`printf '…'` string: your `summary` is free text, and its first apostrophe
|
|
315
|
+
("review 123's finding") ends a single-quoted string and breaks the command.
|
|
316
|
+
|
|
264
317
|
Write this file **once**, at the very end, with your final result. Keep it a flat
|
|
265
318
|
JSON object of exactly the variables in the table above.
|
|
266
319
|
|
|
@@ -115,13 +115,17 @@ your output is **not** parsed. Emit a machine-readable result one of two ways:
|
|
|
115
115
|
once, at the very end. Example (not blocked):
|
|
116
116
|
|
|
117
117
|
```sh
|
|
118
|
-
|
|
118
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
119
|
+
{"scopeBlocked":false,"scopeBlockReason":""}
|
|
120
|
+
EOF
|
|
119
121
|
```
|
|
120
122
|
|
|
121
123
|
Blocked example:
|
|
122
124
|
|
|
123
125
|
```sh
|
|
124
|
-
|
|
126
|
+
cat > "$AGENT_RESULT_FILE" <<'EOF'
|
|
127
|
+
{"scopeBlocked":true,"scopeBlockReason":"#412 requires both the read AND write projection (acceptance criteria 2 + 3); this PR ships only the read side and defers the write projection with no filed tracker. File a follow-up issue for the write projection, link it (Follow-up: #N), and downgrade Closes #412 -> Part of #412 (close #412 by hand only when the write side lands)."}
|
|
128
|
+
EOF
|
|
125
129
|
```
|
|
126
130
|
|
|
127
131
|
2. **Fallback** (only if you cannot write the file): print a single last line to
|
|
@@ -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
|
+
});
|