merge: trekexecute fix-round-4-5b-part-3

This commit is contained in:
Kjell Tore Guttormsen 2026-10-29 08:32:02 +01:00
commit 591f3c4793
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
11 changed files with 1072 additions and 34 deletions

View file

@ -78,6 +78,13 @@ Part 2 fixes the four defects that part 1 introduced and its own review found, p
- **A failed `git check-ignore` writes no lessons pointer, whatever language git speaks** (CKI). `lib/review/recurrence.mjs` no longer reads every `git check-ignore` exit 128 as "no repository": on 128 it runs `git rev-parse --git-dir` and looks for a `.git` entry from the repo up to the nearest `GIT_CEILING_DIRECTORIES` entry above it, and writes the `CLAUDE.md` pointer only when rev-parse also exits 128, `GIT_DIR` is unset and no `.git` entry is found. Anything else gives `check-failed` with a note that names the reason, and no branch reads git's message text. A `.claude` that is a symlink inside the repo now gives `check-failed` (no pointer), also when it points at a tracked directory.
- **The executor records the full commit id for the done line** (DLC). `/trekexecute` Sub-step G says `{hash}` is the full commit id from `git rev-parse HEAD` after the step's last commit, read again after any later commit of the step, never the short id `git commit` prints: `lib/verification/done-line.mjs --check` refuses a short id with exit 2, which sent a passing step to the plan. The progress line stores that full id, and Handover 5's `commit` row says so.
**Fix round 4.5b, part 3 (decision 89)**
Part 3 fixes two MAJOR findings of part 2's review, in the two places part 2 rewrote; each has a test seen failing first at part 2's end point `ce98f62` (`tests/fix-round-4-5b-part-3-red.sh` proves it).
- **A quoted value closes at the first quote of its kind that ends a word, whatever stands before it** (CQW). In `lib/parsers/arg-parser.mjs`, the closing quote no longer has to follow a non-blank character. So `"my dir "`, `" "` and a value closed on its own line pair again. A quote that starts a word still stops the search, so `--since` after `"my dir"/x` stays `HEAD~1`. One qualification: a value whose text ends in whitespace may now hold what follows it, up to a quote that ends a word (see Known issues). It names review finding `87a693f0`.
- **The Bash guard also reads a heredoc opener's word as the shell does** (PQO). `hooks/scripts/pre-bash-executor.mjs` reads the word when it is partly quoted, `$'...'`, `$"..."`, or holds a backslash-newline anywhere from the first `<` to the end of the word. It checks every opener whose word is the parser's delimiter, and treats a word it cannot read as unquoted. Every command it blocked before stays blocked. A typed argument whose `<<` runs into a quote with no partner (`fix "<<" handling`) still passes inside the commands' argument heredoc. It names review finding `021996c6`.
Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyage control could be bypassed or was too lax, or where the text gave a guarantee no code held. Each is fixed, and each has a test that fails on the code before it, except DJ, whose sentences already stood and whose pins fail when they are deleted (`tests/fix-round-4-5a-red.sh` proves every other id red at the round's start point, and DJ under its two mutations).
**Execute**
@ -162,7 +169,10 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
- **The Bash guard knows one heredoc delimiter** (MINOR `8959d2cd`, `hooks/scripts/pre-bash-executor.mjs`; decision 88). It has no general rule for a quoted heredoc's closing line, so another heredoc in the commands, such as a stats record's, can still be closed early by a typed line equal to its delimiter.
- **A command that mentions the parser's heredoc opener with a line after it stays blocked** (fix round 4.5b part 2, decision 88). The guard checks every opener of `VOYAGE_ARGS_EOF_7F3C9E` that has a non-blank line after the opener's line, so such a command stays blocked unless it is a quoted heredoc whose closing line is the one last non-empty line, for example a commit message written as a heredoc that mentions the opener. Write it on one line, or read it from a file.
- **A `bash -c $'...'` command on one physical line is not checked** (fix round 4.5b part 2, decision 88). It carries no literal newline, so the guard sees no line after the opener even when `bash -c` expands it into a heredoc of the parser's delimiter; part 1 blocked it by accident. The shipped commands never produce it from `$ARGUMENTS`.
- **The tokenizer splits a quoted value at a quote of the same kind that starts a word** (TOK, decision 88). `'the '90s were great'` now gives `'the` and `90s were great`; a quoted value that ends in whitespace (`"a "`) no longer pairs and gives `"a` and `"`; and the argv form's self-quoting of a word that holds spaces (`-- 'say "hi"'`) splits. The operator accepted these.
- **The tokenizer splits a quoted value at a quote of the same kind that starts a word** (TOK, decision 88). `'the '90s were great'` gives `'the` and `90s were great` (accepted by the operator on 2026-10-27, part 2's brief). The argv form's self-quoting of a word that holds spaces (`-- 'say "hi"'`) splits (accepted on 2026-10-28). Since part 3, a quoted value that ends in whitespace pairs again, so such a value may also hold what follows it up to a quote that ends a word: `--project "x --since " HEAD` gives `--project` = `x --since `.
- **`gitEntryDir` does not follow git's `GIT_CEILING_DIRECTORIES` rules** (MAJOR `3b98d18b`, `lib/review/recurrence.mjs`; decision 89). It resolves the entries after an empty one through realpath, and it keeps relative entries and entries whose realpath fails. So a ceiling written that way can give `no-git-repo` and a written pointer where git sees a repository.
- **A failed checkpoint commit leaves `{hash}` on the previous step's commit** (MAJOR `69b086eb`, `commands/trekexecute.md`; decision 89). When the checkpoint commit fails, `{hash}` is the current HEAD, which is the previous step's commit. So the done line and the progress record name a commit without step N's changes.
- **Some typed arguments that hold `<<` are refused** (fix round 4.5b part 3, decision 89). A typed argument holding `<<` followed by a word the guard cannot read, other than through a quote with no partner (for example `<<$'\t'` or a backslash inside `"..."`), is refused by `/trekreview`, `/trekcontinue` and `/trekendsession`; write the argument another way.
### Doors
@ -173,6 +183,7 @@ Fix round 4.5a (decisions 57, 67 and 68): the round's 30 findings, where a Voyag
- two-way: fix round 4.5a (decisions 57, 67, 68) — a revert restores the old texts and checks
- two-way: fix round 4.5b (decisions 80 to 87) — a revert restores the old checks, texts and red script and takes out the Bash guard's heredoc rule; `review.md` keeps `review_version` 1.1 either way.
- two-way: fix round 4.5b part 2 (decision 88) — a revert restores part 1's four behaviours (the tokenizer's quote search, the guard's refusal of a one-line mention, the pointer written on any `git check-ignore` exit 128, the undefined `{hash}`) and the guard's first-opener-only check.
- two-way: fix round 4.5b part 3 (decision 89) — a revert restores part 2's closing-quote search (a value whose closing quote follows whitespace splits again) and the guard's regex-only opener check.
## v6.5.0 — 2026-10-18 — Three skills for brief, criteria and review, English labels, and three trial-5 fixes

View file

@ -264,17 +264,149 @@ const commandView = commandPositionView(deobfuscated.replace(/\x1B\[[0-9;]*m/g,
// delimiter would close the heredoc there, and the shell would run every line
// after it as a command (fix round 4.5b, R6). This check reads the raw
// command: `normalized` and `commandView` collapse lines and drop heredoc
// bodies (hooks/lib/bash-view.mjs). Every opener of the delimiter in the
// command is checked, so a quoted first mention cannot hide a later unquoted
// heredoc. bash reads a heredoc body only from the lines after the opener's
// line, so an opener with no non-blank line after its own line (a one-line
// grep or commit message that only mentions it) has no body: nothing can
// close early and an unquoted opener has nothing to expand, so it is skipped.
// An opener with a non-blank line after it is blocked unless it is quoted
// (<<'D', <<"D" or <<\D, whose body is not expanded) and exactly one line,
// trimmed, equals the delimiter with every line after that one empty. A
// command that never names the delimiter skips the whole check.
// bodies (hooks/lib/bash-view.mjs). A command that never names the delimiter
// skips the whole check.
// Every opener in the command is read in two ways, and the command is blocked
// when either reading blocks it (the union):
// 1. A regex finds <<D, <<'D', <<"D" and <<\D, with an optional - and
// spaces (fix round 4.5b and its part 2).
// 2. A shell reading (fix round 4.5b, part 3) starts at every < in the
// text, wherever it stands; like the regex, it does not lex the command
// around it. A backslash directly followed by a line break is a
// continuation: removed, not quoting. The word after << (and an optional
// -, spaces and tabs) is read as the shell reads it: a backslash makes
// the next character literal and quotes the word; '...', "...", $'...'
// and $"..." quote it, so a partly quoted word such as
// <<"VOYAGE"_ARGS_EOF_7F3C9E counts. The word ends at unquoted
// whitespace (a line break included),
// at one of ; & | ( ) < > or at the end of the text. Its body lines are
// the raw lines after the line on which the word ends: continuations
// change only how the word is read, never the line count.
// bash reads a heredoc body only from the lines after the opener's line, so
// an opener with no non-blank line after it (a one-line grep or commit
// message that only mentions it) has no body: nothing can close early and an
// unquoted opener has nothing to expand, so it is skipped. An opener with a
// non-blank line after it is blocked unless it is quoted (its body is not
// expanded) and exactly one line, trimmed, equals the delimiter with every
// line after that one empty (the regex reading counts every line of the
// command, the shell reading the body lines).
// A word the guard cannot read is treated as unquoted, whatever its letters:
// a quote with no partner anywhere in the rest of the text (the word is cut
// there, so its line is the quote's line), any backslash inside "...",
// $"..." or $'...' (no escape there is decoded), a backslash at the end of
// the text, a - reached only after a continuation (bash reads <<-, zsh
// reads the - as part of the word), and a continuation between a $ and the
// quote it opens. The word is compared with the delimiter after trim(), the
// same trim the body lines get, so a CR (a CRLF line), VT, FF or other
// character trim() strips around the word does not hide it.
// After a quoted opener that passes, a later opener is skipped only when its
// word is cut by an unpaired quote and is readable otherwise: the shell meets
// the same unpaired quote, stops with a parse error and runs nothing after it,
// so a typed argument such as fix "<<" handling passes. Every other later
// opener is checked, because the accepted opener may sit inside a string and
// must not hide a real opener after it.
// The shell reading fails closed: an error in it blocks (exit 2), because an
// exit 1 is a non-blocking error and the command would run.
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
function blockArgs(unquoted, unreadable) {
const why = unquoted
? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` +
`Way forward: open it as <<'${ARGS_DELIM}'.`
: `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` +
'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.';
const unread = unreadable ? " The guard could not read the opener's word, so it treats it as unquoted." : '';
process.stderr.write(
'[voyage] BLOCKED: args-heredoc-closed-early\n' +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
` ${why}${unread}\n`
);
process.exit(2);
}
// Each heredoc operator in `text`, in text order, as the shell reads its word:
// { index (the operator's first <), end, word, quoted, unreadable, cutOnly }.
function argsShellOpeners(text) {
const n = text.length;
// The index after any run of continuations that starts at k.
const skip = (k) => {
while (text[k] === '\\' && text[k + 1] === '\n') k += 2;
return k;
};
const openers = [];
let i = text.indexOf('<');
while (i !== -1) {
let next = i + 1;
let k = skip(i + 1);
if (text[k] === '<') {
const second = k;
// The scan resumes right after the second <, never at the word's end,
// so a quoted word that spans lines cannot hide a < inside it.
next = second + 1;
k = skip(second + 1);
if (text[k] === '<') {
next = k + 1; // a here-string: move past it
} else {
let unreadable = false;
if (text[k] === '-') {
// After a continuation, bash reads <<- and zsh reads - as the word.
if (k !== second + 1) unreadable = true;
k = skip(k + 1);
}
while (text[k] === ' ' || text[k] === '\t') k = skip(k + 1);
const start = k;
let word = '';
let quoted = false;
let cut = -1;
while (k < n) {
const c = text[k];
if (c === ' ' || c === '\t' || c === '\n' || ';&|()<>'.includes(c)) break;
if (c === '\\') {
if (text[k + 1] === '\n') { k += 2; continue; }
if (k + 1 >= n) { unreadable = true; k += 1; break; }
word += text[k + 1];
quoted = true;
k += 2;
continue;
}
// A $ opens $'...' or $"..." also across continuations; a
// continuation between the $ and the quote makes the word unreadable.
const q1 = c === '$' ? skip(k + 1) : k + 1;
const dollar = c === '$' && (text[q1] === "'" || text[q1] === '"');
if (dollar && q1 !== k + 1) unreadable = true;
if (c === "'" || c === '"' || dollar) {
const open = dollar ? q1 : k;
const q = text[open];
const close = text.indexOf(q, open + 1);
if (close === -1) { cut = open; break; }
const inner = text.slice(open + 1, close);
if ((q === '"' || dollar) && inner.includes('\\')) unreadable = true;
word += inner;
quoted = true;
k = close + 1;
continue;
}
word += c;
k += 1;
}
const isCut = cut !== -1;
if (k > start || unreadable || isCut) {
openers.push({
index: i,
end: isCut ? cut : k,
word,
quoted,
unreadable: unreadable || isCut,
cutOnly: isCut && !unreadable,
});
}
}
}
i = text.indexOf('<', next);
}
return openers;
}
if (command.includes(ARGS_DELIM)) {
const argsOpeners = command.matchAll(new RegExp(`<<-?\\s*(['"\\\\]?)${ARGS_DELIM}(['"]?)`, 'g'));
const argsLines = command.split('\n');
@ -287,20 +419,37 @@ if (command.includes(ARGS_DELIM)) {
if (!hasBody) continue;
const [, open, close] = argsOpener;
const quoted = open === '\\' ? close === '' : open !== '' && close === open;
if (!quoted || !closesOnceAtEnd) {
const why = !quoted
? `The heredoc that hands the arguments to the arg parser opens ${ARGS_DELIM} unquoted, so the shell would expand its body. ` +
`Way forward: open it as <<'${ARGS_DELIM}'.`
: `The arguments hold a line equal to the heredoc's closing line ${ARGS_DELIM}, or text follows that closing line, ` +
'so the shell would run what follows as commands. Way forward: take that line out of the arguments and run the command again.';
process.stderr.write(
'[voyage] BLOCKED: args-heredoc-closed-early\n' +
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
` ${why}\n`
);
process.exit(2);
}
if (!quoted || !closesOnceAtEnd) blockArgs(!quoted, false);
}
// The shell reading. The loop records the first failing verdict; an error
// anywhere in it blocks.
let argsVerdict = null;
try {
let bodyFrom = Infinity;
for (const o of argsShellOpeners(command)) {
if (o.index >= bodyFrom && o.cutOnly) continue;
// The word is compared trimmed, as the body lines are: a word of D plus
// characters trim() strips (a CR from a CRLF line, a VT, an FF, a
// no-break space) is closed by a line equal to it, which trims to D.
if (o.word.trim() !== ARGS_DELIM && !o.unreadable) continue;
const nl = command.indexOf('\n', o.end);
if (nl === -1) continue;
// The raw lines after the line on which the word ends.
const body = argsLines.slice(command.slice(0, nl).split('\n').length);
if (!body.some((line) => line.trim() !== '')) continue;
const isQuoted = o.quoted && !o.unreadable;
const closing = body.flatMap((l, i) => (l.trim() === ARGS_DELIM ? [i] : []));
if (isQuoted && closing.length === 1 && body.slice(closing[0] + 1).every((line) => line.trim() === '')) {
if (bodyFrom === Infinity) bodyFrom = nl + 1;
continue;
}
argsVerdict = [!isQuoted, o.unreadable];
break;
}
} catch {
blockArgs(true, true);
}
if (argsVerdict) blockArgs(argsVerdict[0], argsVerdict[1]);
}
// END args-heredoc-closed-early

View file

@ -137,15 +137,16 @@ const isSpace = (c) => /\s/.test(c);
* Split argument text into words. Words are separated by whitespace. A word
* that starts with `'` or `"` opens a quote. Its closing quote is the next
* same-kind quote that ends a word (followed by whitespace or the end of the
* text) and does not start one; a same-kind quote inside a word is skipped.
* The search stops at a same-kind quote that starts a word (one preceded by
* whitespace): then there is no pair. A pair groups the words between the
* quotes into one value and is removed. A quote with no partner is a literal
* character (`user's` stays `user's`). Examples: `'Fix the user's login'` is
* one value, `Fix the user's login`; `"my dir"/x --since "HEAD~1"` gives the
* literal words `"my` and `dir"/x`, then `--since` and `HEAD~1`, so a quoted
* value never swallows a later flag. Nothing is expanded: the function only
* slices the string.
* text), whatever stands before it: a letter, whitespace or a line break. A
* same-kind quote that starts a word (whitespace before it, a non-whitespace
* character after it) stops the search: then there is no pair. Any other
* same-kind quote (inside a word) is skipped. A pair groups the words between
* the quotes into one value and is removed. A quote with no partner is a
* literal character (`user's` stays `user's`). Examples: `"my dir "` is one
* value, `my dir `; `'Fix the user's login'` is one value,
* `Fix the user's login`; `"my dir"/x --since "HEAD~1"` gives the literal
* words `"my` and `dir"/x`, then `--since` and `HEAD~1`. Nothing is expanded:
* the function only slices the string.
*
* @param {string} text
* @returns {Array<{ value: string, start: number, end: number }>}
@ -163,8 +164,11 @@ export function tokenize(text) {
const q = text[i];
if (q === '"' || q === "'") {
let j = text.indexOf(q, i + 1);
while (j !== -1 && !isSpace(text[j - 1]) && j + 1 < n && !isSpace(text[j + 1])) j = text.indexOf(q, j + 1);
if (j !== -1 && !isSpace(text[j - 1])) {
while (j !== -1 && j + 1 < n && !isSpace(text[j + 1])) {
if (isSpace(text[j - 1])) { j = -1; break; }
j = text.indexOf(q, j + 1);
}
if (j !== -1) {
out.push({ value: text.slice(i + 1, j), start, end: j + 1 });
i = j + 1;
continue;

View file

@ -0,0 +1,330 @@
#!/usr/bin/env bash
# tests/e2e-fix-round-4-5b-part-3.sh
# End to end for fix round 4-5b, part 3 (plan
# .claude/projects/2026-10-28-fix-round-4-5b-part-3, End-to-end:, decision
# 89): the shipped products run the way a session runs them, in a temp
# directory.
# 1. CQW, the shipped way: the parser block of commands/trekreview.md
# (## Phase 1), with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in, passes
# the Bash hook, and /bin/bash runs it to JSON that keeps a value closed
# on its own line.
# 2. CQW through the parser CLI's --stdin: `"my dir "` pairs, and
# `--project "my dir"/x --since "HEAD~1"` keeps --since = HEAD~1.
# 3. PQO through the Bash hook (hooks/scripts/pre-bash-executor.mjs):
# partly quoted, $'...' and backslash-newline openers are blocked; a
# string-borne << hides no real opener; the shipped form with a typed
# `fix "<<" handling` argument and the agents/review-orchestrator.md
# block pass.
# 4. PQO premise: /bin/bash itself runs the line after the closing line of
# a partly quoted opener, so blocking it is not a false positive.
# 5. PQO fix rounds 1 and 2 of step 2, through the Bash hook: a
# continuation between a $ and its quote (at the word's start and inside
# it) is blocked; a partly quoted opener whose word ends in a CR (CRLF
# lines) or a VT, closed by the same line, is blocked when a line follows
# the closing line; with CRLF lines and the closing line last it passes.
# Prints one "ok - <case>" or "FAIL - <case>: <why>" line per case and exits 1
# when any case failed, else 0. Starts nothing but node and /bin/bash (and the
# usual file tools). bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "FAIL - setup: cannot cd to the repo root"; exit 1; }
ROOT="$(pwd)"
ARGS="$ROOT/lib/parsers/arg-parser.mjs"
HOOK="$ROOT/hooks/scripts/pre-bash-executor.mjs"
unset CLAUDE_CODE_SESSION_ID
TMP="$(mktemp -d "${TMPDIR:-/tmp}/e2e-fix-round-4-5b-part-3.XXXXXX")" || { echo "FAIL - setup: mktemp"; exit 1; }
trap 'rm -rf "$TMP"' EXIT
TMP="$(cd "$TMP" && pwd -P)" || { echo "FAIL - setup: cannot enter the temp directory"; exit 1; }
export LC_ALL=C
export LANG=C
FAILED=0
pass() { echo "ok - $1"; }
bad() { echo "FAIL - $1"; FAILED=$((FAILED + 1)); }
# jsonok <js expression on j>: stdin is JSON; exit 0 when the expression is true.
jsonok() {
node -e 'let s="";process.stdin.on("data",(d)=>{s+=d;}).on("end",()=>{let j;try{j=JSON.parse(s);}catch(e){process.exit(3);}process.exit(Function("j","return ("+process.argv[1]+");")(j)?0:1);});' "$1"
}
# hookrun <out> <line>...: the Bash hook with a Bash tool input whose command
# is the given lines joined by newlines; stderr goes to <out>. Returns the
# hook's exit status.
hookrun() {
out="$1"
shift
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: process.argv.slice(1).join("\n") } }));' "$@" \
| node "$HOOK" > /dev/null 2> "$out"
}
# hookfile <out> <file>: the Bash hook with the file's text as the command.
hookfile() {
node -e 'process.stdout.write(JSON.stringify({ tool_name: "Bash", tool_input: { command: require("fs").readFileSync(process.argv[1], "utf8") } }));' "$2" \
| node "$HOOK" > /dev/null 2> "$1"
}
D=VOYAGE_ARGS_EOF_7F3C9E
PARSER_LINE='node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin'
# The typed arguments: a quoted value closed on its own line.
TYPED="$(printf '%s\n%s' '--project "notes' '" --since HEAD')"
# ---- 1. CQW, the shipped way ----
case_cqw_shipped() {
name="trekreview Phase 1 block: passes the hook and keeps a value closed on its own line"
W="$TMP/shipped"
mkdir -p "$W" || { bad "$name: mkdir"; return; }
# The block as written, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in
# the way Claude Code fills them.
node -e '
const fs = require("fs");
const [md, root, args] = process.argv.slice(1);
const text = fs.readFileSync(md, "utf8");
const s = text.indexOf("## Phase 1 ");
if (s === -1) process.exit(3);
const rest = text.slice(s);
const e = rest.search(/\n## /);
const phase = e === -1 ? rest : rest.slice(0, e);
const blocks = phase.split("\n```bash\n").slice(1).map((b) => b.split("\n```")[0]).filter((b) => b.includes("<<" + String.fromCharCode(39) + "VOYAGE_ARGS_EOF_7F3C9E" + String.fromCharCode(39)));
if (blocks.length !== 1) { console.error("expected one Phase 1 parser block, found " + blocks.length); process.exit(3); }
process.stdout.write(blocks[0].split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/commands/trekreview.md" "$ROOT" "$TYPED" > "$TMP/phase1.sh" \
|| { bad "$name: could not read the Phase 1 block"; return; }
hookfile "$TMP/shipped.err" "$TMP/phase1.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/shipped.err")"
return
fi
( cd "$W" && /bin/bash "$TMP/phase1.sh" ) > "$TMP/shipped.json" 2> "$TMP/shipped.out"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the block exited $c: $(cat "$TMP/shipped.out")"
elif ! jsonok 'j.flags["--project"] === "notes\n" && j.flags["--since"] === "HEAD" && j.positional.length === 0' < "$TMP/shipped.json"; then
bad "$name: the parse is not --project = notes + line break, --since = HEAD, no positional: $(cat "$TMP/shipped.json")"
else
pass "$name (hook exit 0; --project = notes + line break, --since = HEAD)"
fi
}
# ---- 2. CQW through the parser CLI's --stdin ----
case_cqw_cli() {
name="arg-parser --stdin: \"my dir \" pairs, and --since stays HEAD~1"
printf '%s\n' '--project "my dir " --since HEAD' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-1.json" 2>&1
c1=$?
printf '%s\n' '--project "my dir"/x --since "HEAD~1"' | node "$ARGS" --command trekreview --stdin > "$TMP/cli-2.json" 2>&1
c2=$?
if [ "$c1" -ne 0 ] || [ "$c2" -ne 0 ]; then
bad "$name: the parser exited $c1 / $c2: $(cat "$TMP/cli-1.json" "$TMP/cli-2.json")"
elif ! jsonok 'j.flags["--project"] === "my dir " && j.flags["--since"] === "HEAD"' < "$TMP/cli-1.json"; then
bad "$name: --project is not \"my dir \": $(cat "$TMP/cli-1.json")"
elif ! jsonok 'j.flags["--since"] === "HEAD~1"' < "$TMP/cli-2.json"; then
bad "$name: --since is not HEAD~1: $(cat "$TMP/cli-2.json")"
else
pass "$name"
fi
}
# ---- 3. PQO through the Bash hook ----
# blocked <name> <out> <line>...: the hook exits 2 and names args-heredoc-closed-early.
blocked() {
bname="$1"
bout="$2"
shift 2
hookrun "$bout" "$@"
c=$?
if [ "$c" -ne 2 ]; then
bad "$bname: the hook exited $c, not 2: $(cat "$bout")"
return 1
elif ! grep -q 'args-heredoc-closed-early' "$bout"; then
bad "$bname: the hook did not name args-heredoc-closed-early: $(cat "$bout")"
return 1
fi
return 0
}
# unquoted <name> <out> <line>...: blocked, with the unquoted reason.
unquoted() {
qname="$1"
uout="$2"
shift 2
blocked "$qname" "$uout" "$@" || return 1
if ! grep -q "opens $D unquoted" "$uout"; then
bad "$qname: the hook did not give the unquoted reason: $(cat "$uout")"
return 1
fi
return 0
}
# allowed <name> <out> <line>...: the hook exits 0.
allowed() {
aname="$1"
aout="$2"
shift 2
hookrun "$aout" "$@"
c=$?
if [ "$c" -ne 0 ]; then
bad "$aname: the hook exited $c: $(cat "$aout")"
return 1
fi
return 0
}
case_pqo_partly_quoted() {
name='Bash hook: a partly quoted opener <<"VOYAGE"_ARGS_EOF_7F3C9E that closes early is blocked'
blocked "$name" "$TMP/pqo-1.err" "$PARSER_LINE "'<<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_dollar() {
name="Bash hook: a \$'...' opener that closes early is blocked"
blocked "$name" "$TMP/pqo-2.err" "$PARSER_LINE <<\$'$D'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_continuation() {
name='Bash hook: a backslash-newline opener that closes early is blocked'
blocked "$name" "$TMP/pqo-3.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_pqo_continuation_last() {
name='Bash hook: a backslash-newline opener with the closing line last is blocked as unquoted'
unquoted "$name" "$TMP/pqo-4.err" "$PARSER_LINE"' <<\' "$D" 'brief.md' "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_fake_accepted() {
name='Bash hook: a fake accepted opener in a string hides no real opener'
unquoted "$name" "$TMP/pqo-5.err" "echo \"<<'$D' x\"" "$PARSER_LINE"' <<VOYAGE_ARGS\' '_EOF_7F3C9E' 'brief.md' "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_spanning_word() {
name='Bash hook: a quoted word spanning lines in a string hides no real opener'
unquoted "$name" "$TMP/pqo-6.err" "echo \"<<'\"" "$PARSER_LINE"' <<VOYAGE_ARGS\' '_EOF_7F3C9E' "brief.md don't" "$D" \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_pqo_typed_argument() {
name='Bash hook: the shipped form with the typed argument fix "<<" handling passes'
allowed "$name" "$TMP/pqo-7.err" "$PARSER_LINE <<'$D'" 'fix "<<" handling' "$D" \
&& pass "$name (exit 0)"
}
case_pqo_orchestrator() {
name='Bash hook: the agents/review-orchestrator.md parser block passes'
# The lines from the one holding the opener through the next line equal to
# the delimiter, with ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS filled in.
node -e '
const fs = require("fs");
const [md, root, args, delim] = process.argv.slice(1);
const lines = fs.readFileSync(md, "utf8").split("\n");
const opener = "--stdin <<" + String.fromCharCode(39) + delim + String.fromCharCode(39);
const s = lines.findIndex((l) => l.includes(opener));
if (s === -1) { console.error("no line holds " + opener); process.exit(3); }
const e = lines.findIndex((l, i) => i > s && l === delim);
if (e === -1) { console.error("no closing line after the opener"); process.exit(3); }
process.stdout.write(lines.slice(s, e + 1).join("\n").split("${CLAUDE_PLUGIN_ROOT}").join(root).split("$ARGUMENTS").join(args) + "\n");
' "$ROOT/agents/review-orchestrator.md" "$ROOT" "$TYPED" "$D" > "$TMP/orchestrator.sh" \
|| { bad "$name: could not read the parser block"; return; }
hookfile "$TMP/pqo-8.err" "$TMP/orchestrator.sh"
c=$?
if [ "$c" -ne 0 ]; then
bad "$name: the hook exited $c: $(cat "$TMP/pqo-8.err")"
else
pass "$name (exit 0)"
fi
}
# ---- 4. PQO premise: bash runs the line after the closing line ----
case_pqo_premise() {
name='/bin/bash runs the line after the closing line of a partly quoted opener'
P="$TMP/premise"
mkdir -p "$P" || { bad "$name: mkdir"; return; }
printf '%s\n' 'cat > /dev/null <<"VOYAGE"_ARGS_EOF_7F3C9E' 'brief.md' "$D" "touch \"$TMP/ran\"" > "$TMP/premise.sh" \
|| { bad "$name: could not write the script"; return; }
( cd "$P" && /bin/bash "$TMP/premise.sh" ) > "$TMP/premise.out" 2>&1
if [ ! -e "$TMP/ran" ]; then
bad "$name: the line after the closing line did not run: $(cat "$TMP/premise.out")"
else
pass "$name (the block is not a false positive)"
fi
}
# ---- 5. PQO fix rounds: a continuation after $, a CR or VT word end ----
# The CR and VT bytes, built with printf escapes so this file stays ASCII.
CR="$(printf '\r')"
VT="$(printf '\013')"
case_fix_bytes() {
name='fix-round cases: the CR and VT bytes were built'
if [ "${#CR}" -ne 1 ] || [ "${#VT}" -ne 1 ]; then
bad "$name: CR is ${#CR} byte(s), VT is ${#VT} byte(s), not 1 each"
else
pass "$name"
fi
}
case_fix_dollar_continuation() {
name="Bash hook: <<\$ + backslash-newline + '$D' that closes early is blocked as unquoted"
unquoted "$name" "$TMP/fix-1.err" "$PARSER_LINE"' <<$\' "'$D'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, opens $D unquoted)"
}
case_fix_midword_dollar_continuation() {
name="Bash hook: <<VOYAGE\$ + backslash-newline + '_ARGS_EOF_7F3C9E' that closes early is blocked"
blocked "$name" "$TMP/fix-2.err" "$PARSER_LINE"' <<VOYAGE$\' "'_ARGS_EOF_7F3C9E'" 'brief.md' "$D" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_crlf_partly_quoted() {
name='Bash hook: a partly quoted opener with CRLF lines that closes early is blocked'
blocked "$name" "$TMP/fix-3.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$CR" "brief.md$CR" "$D$CR" "touch PWNED$CR" \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_vt_partly_quoted() {
name='Bash hook: a partly quoted opener whose word ends in a VT, closed by D + VT, is blocked'
blocked "$name" "$TMP/fix-4.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$VT" 'brief.md' "$D$VT" 'touch PWNED' \
&& pass "$name (exit 2, args-heredoc-closed-early)"
}
case_fix_crlf_closing_last() {
name='Bash hook: a partly quoted opener with CRLF lines and the closing line last passes'
allowed "$name" "$TMP/fix-5.err" "$PARSER_LINE"' <<"VOYAGE"_ARGS_EOF_7F3C9E'"$CR" "brief.md$CR" "$D$CR" \
&& pass "$name (exit 0)"
}
case_cqw_shipped
case_cqw_cli
case_pqo_partly_quoted
case_pqo_dollar
case_pqo_continuation
case_pqo_continuation_last
case_pqo_fake_accepted
case_pqo_spanning_word
case_pqo_typed_argument
case_pqo_orchestrator
case_pqo_premise
case_fix_bytes
case_fix_dollar_continuation
case_fix_midword_dollar_continuation
case_fix_crlf_partly_quoted
case_fix_vt_partly_quoted
case_fix_crlf_closing_last
if [ "$FAILED" -ne 0 ]; then
echo "e2e: $FAILED case(s) failed"
exit 1
fi
echo "e2e: ok"
exit 0

View file

@ -0,0 +1,10 @@
// tests/fix-round-4-5b-part-3-mutations.mjs
// The mutations of fix round 4-5b, part 3, for the shared red script
// (tests/helpers/red-round.sh, through tests/helpers/red-mutate.mjs). The
// round has none: every part-3 id (CQW, PQO, KI3) has tests that fail on an
// assertion at the round's start point ce98f62, on that id's own code or
// text, so no id needs a mutation to be proven red. red-mutate.mjs list
// accepts the empty array, and the red script then reports 0 mutation(s).
// Data only; never matched by `npm test`.
export const MUTATIONS = [];

View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-part-3-red.sh
# The red proof of fix round 4-5b, part 3 (plan
# .claude/projects/2026-10-28-fix-round-4-5b-part-3), as a wrapper over the
# shared red script tests/helpers/red-round.sh. Every id of the round has
# tests whose names start with "[id]"; each of them must fail on an assertion
# at the round's start point ce98f62.
# This wrapper passes the shared script:
# --base BASE, --ids IDS (the round's 3 ids), the round's FILES;
# --other-ids: the ids of fix round 4-5a, of 4-5b part 1 and of 4-5b
# part 2, read with sed from the IDS= lines of tests/fix-round-4-5a-red.sh,
# tests/fix-round-4-5b-red.sh and tests/fix-round-4-5b-part-2-red.sh and
# passed as ONE value, in that order (the shared script keeps only the
# last --other-ids it is given); a tag on that list is not judged;
# --mutations tests/fix-round-4-5b-part-3-mutations.mjs (empty: every id is
# red at the start point on its own code or text).
# No --require-sha256: the round has no SHA-256 test.
# Exit 0 all red, 1 a rule broken (the judge names it), 2 a setup failure
# (also: no ids on one of the three older IDS= lines); never 0 on a setup
# failure.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
set -f
cd "$(dirname "$0")/.." || { echo "red script: setup failure: cannot cd to the repo root" >&2; exit 2; }
ROOT="$(pwd -P)"
BASE=ce98f62e82af1e2c5c33cbe63808b7ec52cac000
IDS='CQW PQO KI3'
FILES='tests/lib/arg-parser-closing-quote.test.mjs tests/hooks/bash-guard-opener-word.test.mjs tests/lib/fix-round-4-5b-part-3-prose.test.mjs'
OTHER_A=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5a-red.sh")
if [ -z "$OTHER_A" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5a-red.sh" >&2
exit 2
fi
OTHER_B=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-red.sh")
if [ -z "$OTHER_B" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-red.sh" >&2
exit 2
fi
OTHER_C=$(sed -n "s/^IDS='\(.*\)'\$/\1/p" "$ROOT/tests/fix-round-4-5b-part-2-red.sh")
if [ -z "$OTHER_C" ]; then
echo "red script: setup failure: no ids on the IDS= line of $ROOT/tests/fix-round-4-5b-part-2-red.sh" >&2
exit 2
fi
exec /bin/bash "$ROOT/tests/helpers/red-round.sh" --base "$BASE" --ids "$IDS" --other-ids "$OTHER_A $OTHER_B $OTHER_C" --mutations "$ROOT/tests/fix-round-4-5b-part-3-mutations.mjs" $FILES

View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
# tests/fix-round-4-5b-part-3-regression.sh
# The Regression: of fix round 4-5b, part 3: the whole suite (npm test),
# part 2's end to end (tests/e2e-fix-round-4-5b-part-2.sh, which drives the
# arg parser and the args-heredoc guard, both changed by part 3), part 1's
# end to end (tests/e2e-fix-round-4-5b.sh, which drives the arg parser) and
# part 3's red proof (tests/fix-round-4-5b-part-3-red.sh). The 4-5a, part-1
# and part-2 red proofs are not run here: one Regression command has the
# criteria runner's 15-minute cap; they run as their own Verification lines.
# All four run; the script prints each exit and exits with the first non-zero
# status, or 0 when all four exit 0.
# bash 3.2 compatible, ASCII only, no arrays.
set -u
cd "$(dirname "$0")/.." || { echo "regression: cannot cd to the repo root" >&2; exit 2; }
FIRST=0
npm test
rc=$?
echo "regression: npm test exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b-part-2.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b-part-2.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/e2e-fix-round-4-5b.sh
rc=$?
echo "regression: tests/e2e-fix-round-4-5b.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
/bin/bash tests/fix-round-4-5b-part-3-red.sh
rc=$?
echo "regression: tests/fix-round-4-5b-part-3-red.sh exited $rc"
[ "$FIRST" -eq 0 ] && FIRST=$rc
exit "$FIRST"

View file

@ -0,0 +1,175 @@
// tests/hooks/bash-guard-opener-word.test.mjs
// [PQO] Fix round 4.5b, part 3 (decision 89).
// Defect: the args-heredoc guard in hooks/scripts/pre-bash-executor.mjs found
// heredoc openers with one regex that needs the whole word quoted, so an
// opener whose word is only partly quoted (<<"VOYAGE"_ARGS_EOF_7F3C9E), quoted
// as $'...' or $"...", or split by a backslash-newline was not recognised: the
// guard exited 0 and the shell ran the line after the closing line. The guard
// must read the opener's word as the shell does, and treat a word it cannot
// read as unquoted.
// The helpers ARGS_DELIM, ARGS_RULE, PARSER_LINE, bashInput,
// expectArgsBlocked and expectArgsAllowed are copied from
// tests/hooks/bash-guard.test.mjs:21-23,327-341, not imported, because
// importing a test file would run its tests. The guard is only spawned, so
// this file loads at ce98f62 and its red is an assertion.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runHook } from '../helpers/hook-helper.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
function bashInput(command) {
return { tool_name: 'Bash', tool_input: { command } };
}
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const ARGS_RULE = /args-heredoc-closed-early/;
const PARSER_LINE = 'node /plugin/lib/parsers/arg-parser.mjs --command trekendsession --stdin';
async function expectArgsBlocked(command) {
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
assert.match(stderr, ARGS_RULE, `the guard did not name args-heredoc-closed-early for: ${JSON.stringify(command)}`);
assert.strictEqual(code, 2, `the guard did not block: ${JSON.stringify(command)}`);
return stderr;
}
async function expectArgsAllowed(command) {
const { code, stderr } = await runHook(PRE_BASH, bashInput(command));
assert.doesNotMatch(stderr, ARGS_RULE, `the guard refused a well-formed heredoc: ${JSON.stringify(command)}`);
assert.strictEqual(code, 0, `the guard refused a well-formed heredoc: ${stderr}`);
}
// The unquoted reason: the guard blocks and says the opener is unquoted.
const UNQUOTED = /opens VOYAGE_ARGS_EOF_7F3C9E unquoted/;
async function expectUnquoted(command) {
const stderr = await expectArgsBlocked(command);
assert.match(stderr, UNQUOTED, `the guard did not give the unquoted reason for: ${JSON.stringify(command)}`);
}
const D = ARGS_DELIM;
const BODY = `\nbrief.md\n${D}`;
const EARLY = `${BODY}\ntouch PWNED`;
// The five backslash-newline openers (the brief's).
const CONTINUATION_OPENERS = [
`<<\\\n${D}`,
`<<VOYAGE_ARGS\\\n_EOF_7F3C9E`,
`<\\\n<${D}`,
`<< \\\n${D}`,
`<<-\\\n${D}`,
];
const X5F_OPENER = `<<$'VOYAGE\\x5fARGS_EOF_7F3C9E'`;
const DQ_CONTINUATION_OPENERS = [
`<<"VOYAGE_ARGS\\\n_EOF_7F3C9E"`,
`<<VOYAGE"_ARGS\\\n_EOF_7F3C9E"`,
];
const OCTAL_OPENER = `<<$'\\126\\117YAGE_ARGS_EOF_7F3C9E'`;
const DASH_AFTER_CONTINUATION_OPENER = `<<\\\n-"VOYAGE"_ARGS_EOF_7F3C9E`;
const UNTERMINATED_OPENER = `<<V"OYAGE_ARGS_EOF_7F3C9E`;
const LOCALE_BACKSLASH_OPENER = `<<$"VOYAGE\\_ARGS_EOF_7F3C9E"`;
// The brief's 11 spellings.
const BRIEF_SPELLINGS = [
`<<"VOYAGE"_ARGS_EOF_7F3C9E`,
`<<V'OYAGE_ARGS_EOF_7F3C9E'`,
`<<VOYAGE\\_ARGS_EOF_7F3C9E`,
`<<$'VOYAGE_ARGS_EOF_7F3C9E'`,
`<<- "VOYAGE"_ARGS_EOF_7F3C9E`,
X5F_OPENER,
...CONTINUATION_OPENERS,
];
// 8 from planning, each measured to close at the plain line or shell-dependent.
const PLANNING_SPELLINGS = [
...DQ_CONTINUATION_OPENERS,
OCTAL_OPENER,
`<<$"VOYAGE_ARGS_EOF_7F3C9E"`,
DASH_AFTER_CONTINUATION_OPENER,
`<<-\t"VOYAGE"_ARGS_EOF_7F3C9E`,
UNTERMINATED_OPENER,
LOCALE_BACKSLASH_OPENER,
];
test('[PQO] the guard reads a heredoc opener\'s word as the shell does and checks every spelling of the delimiter', async () => {
// Red: every spelling, followed by a body, the closing line and a command.
assert.equal(BRIEF_SPELLINGS.length + PLANNING_SPELLINGS.length, 19);
for (const opener of [...BRIEF_SPELLINGS, ...PLANNING_SPELLINGS]) {
await expectArgsBlocked(`${PARSER_LINE} ${opener}${EARLY}`);
}
// The unterminated quote the regex does not match also gets the unquoted reason.
await expectUnquoted(`${PARSER_LINE} ${UNTERMINATED_OPENER}${EARLY}`);
// Red: word ends. Unquoted whitespace or one of ; & | ( ) < > ends the word.
for (const end of [';echo x', '>/dev/null', '|cat', '&', '</dev/null', ')']) {
await expectArgsBlocked(`${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E${end}${EARLY}`);
}
// Red: a fake accepted opener inside a string hides no real opener after it.
await expectUnquoted(`echo "<<'${D}' x"\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
await expectUnquoted(`echo "<<'${D}' x"\n${PARSER_LINE} <<\\\n-VOYAGE_ARGS_EOF_7F3C9E\nbrief.md $(touch PWNED)\n${D}`);
// Red: a quoted word spanning lines hides nothing, in both quote kinds.
await expectUnquoted(`echo "<<'"\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED) don't\n${D}`);
await expectUnquoted(`echo '<<"'\n${PARSER_LINE} <<VOYAGE_ARGS\\\n_EOF_7F3C9E\nbrief.md $(touch PWNED) say "hi\n${D}`);
// Red: a typed argument with another unreadable word after << (Known issue 3b).
await expectUnquoted(`${PARSER_LINE} <<'${D}'\nx <<$'\\t' y\n${D}`);
// Red: lines stay raw. A body line ending in a backslash does not join the
// early closing line to it.
await expectArgsBlocked(`${PARSER_LINE} <<"VOYAGE"_ARGS_EOF_7F3C9E\nfoo\\\n${D}\ntouch PWNED\n${D}`);
// Red: unquoted reason, with EARLY and with BODY (closing line last).
const unquotedOpeners = [
...CONTINUATION_OPENERS,
X5F_OPENER,
...DQ_CONTINUATION_OPENERS,
OCTAL_OPENER,
DASH_AFTER_CONTINUATION_OPENER,
LOCALE_BACKSLASH_OPENER,
];
for (const opener of unquotedOpeners) {
await expectUnquoted(`${PARSER_LINE} ${opener}${EARLY}`);
await expectUnquoted(`${PARSER_LINE} ${opener}${BODY}`);
}
// Control: an unterminated "-quoted delimiter with a body is blocked as unquoted.
await expectUnquoted(`${PARSER_LINE} <<"${D}\nbrief.md`);
// Control: well-formed spellings with the closing line last pass.
for (const opener of [
`<<"VOYAGE"_ARGS_EOF_7F3C9E`,
`<<V'OYAGE_ARGS_EOF_7F3C9E'`,
`<<VOYAGE\\_ARGS_EOF_7F3C9E`,
`<<$'VOYAGE_ARGS_EOF_7F3C9E'`,
`<<- "VOYAGE"_ARGS_EOF_7F3C9E`,
`<<$"VOYAGE_ARGS_EOF_7F3C9E"`,
`<<"VOYAGE"_ARGS_EOF_7F3C9E;echo x`,
]) {
await expectArgsAllowed(`${PARSER_LINE} ${opener}${BODY}`);
}
// Control: the shipped form, and the backslash-quoted opener.
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nbrief.md Session 2 of 3\n${D}`);
await expectArgsAllowed(`${PARSER_LINE} <<\\${D}${BODY}`);
// Control: typed arguments inside the shipped heredoc pass (the operator's decision).
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nfix "<<" handling\n${D}`);
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nFix <<don't>> bug\n${D}`);
await expectArgsAllowed(`${PARSER_LINE} <<'${D}'\nx <<"VOYAGE"_ARGS_EOF_7F3C9E y\n${D}`);
// Control: a continuation before the opener passes.
await expectArgsAllowed(`node /plugin/lib/parsers/arg-parser.mjs --command trekendsession \\\n --stdin <<'${D}'${BODY}`);
// Control: a one-line mention passes; with a line after it, it is blocked.
await expectArgsAllowed(`grep -n "<<'${D}'" f`);
await expectArgsBlocked(`grep -n "<<'${D}'" f\necho x`);
// Control: a delimiter line before a correct heredoc is blocked.
await expectArgsBlocked(`echo hi\n${D}\n${PARSER_LINE} <<'${D}'${BODY}`);
// Control: openers cut off at the end of the text never make the guard exit 1.
await expectArgsAllowed(`echo ${D}\nx <<\\`);
await expectArgsAllowed(`echo ${D}\nx <<"`);
await expectArgsAllowed(`echo ${D}\nx <<$'`);
await expectArgsAllowed(`echo ${D}\nx <\\`);
});

View file

@ -0,0 +1,29 @@
// [CQW] Fix round 4.5b, part 3 (decision 89).
// Defect: `tokenize` refused a closing quote that had whitespace or a line
// break before it, so `"multi line` + line break + `"` (the form the `--stdin`
// heredoc produces), `--project "my dir "`, `"a "` and `" "` never paired and
// their quotes stayed literal characters. A same-kind quote that ends a word
// must close the value, whatever stands before it.
// This file must load at ce98f62: it imports only `parseArgs` and `tokenize`,
// which exist there, so its red is an assertion, not a crash.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { parseArgs, tokenize } from '../../lib/parsers/arg-parser.mjs';
const values = (text) => tokenize(text).map((t) => t.value);
test('[CQW] a quoted value closes at a quote that ends a word, also after whitespace or a line break', () => {
// Red at the start point: the closing quote has whitespace before it.
assert.deepEqual(values('"multi line\n" --since HEAD'), ['multi line\n', '--since', 'HEAD']);
const r = parseArgs('--project "my dir "', 'trekreview');
assert.equal(r.flags['--project'], 'my dir ');
assert.deepEqual(r.positional, []);
assert.deepEqual(values('"a "'), ['a ']);
assert.deepEqual(values('" "'), [' ']);
// Control: a quote that starts a word stops the search; a quote inside a
// word is skipped; a quote that ends a word after a letter still closes.
assert.equal(parseArgs('--project "my dir"/x --since "HEAD~1"', 'trekreview').flags['--since'], 'HEAD~1');
assert.deepEqual(values("'Fix the user's login'"), ["Fix the user's login"]);
assert.deepEqual(values("'the '90s were great'"), ["'the", '90s were great']);
});

View file

@ -0,0 +1,117 @@
// tests/lib/fix-round-4-5b-part-3-prose.test.mjs
// Prose pins for fix round 4-5b, part 3 (plan
// .claude/projects/2026-10-28-fix-round-4-5b-part-3, decision 89): the fixes
// that are text a session reads. The tag in brackets is the part-3 id from the
// round's brief; the tagged test fails on an assertion at ce98f62, part 2's end
// point. The keep-behaviour checks sit inside the tagged test as "Control:"
// assertions.
// KI3 the CHANGELOG lists part 3's fixes (CQW, PQO) under ### Fixed, its
// door under ### Doors, the review's other two MAJOR (`3b98d18b`,
// `69b086eb`) and the typed arguments the guard now refuses under
// ### Known issues, and the tokenizer Known-issues line says exactly
// what is left and when the operator accepted it.
// No static import of a new export: the file must load at ce98f62.
// Fix the SOURCE a pin guards, not the pin.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const readCache = new Map();
/** A repo file's text; the file must exist. Read from disk once per run. */
function read(rel) {
if (!readCache.has(rel)) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
readCache.set(rel, readFileSync(join(ROOT, rel), 'utf8'));
}
return readCache.get(rel);
}
/** Whitespace (line breaks too) collapsed to one space. */
const flat = (s) => s.replace(/\s+/g, ' ').trim();
// --- KI3: the round's CHANGELOG entry ----------------------------------------------
// Part 3 stands in the same Unreleased section as parts 1 and 2. The section is
// found by its title, by the rule tests/lib/fix-round-4-5b-part-2-prose.test.mjs
// uses (copied, not imported): `## Unreleased — <title>` before the release,
// `## vX.Y.Z — <date> — <title>` after it, exactly once, up to the next `## `.
const CHANGELOG_TITLE = "The annotating skill, the run coupling and P10's model-free part";
const CHANGELOG_HEADING = new RegExp(`^## (?:Unreleased|v\\d+\\.\\d+\\.\\d+ — \\d{4}-\\d{2}-\\d{2}) — ${CHANGELOG_TITLE.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
/** The lines of the round's CHANGELOG section, heading excluded. */
function changelogSection() {
const lines = read('CHANGELOG.md').split('\n');
const at = [];
lines.forEach((l, i) => { if (CHANGELOG_HEADING.test(l.replace(/\s+$/, ''))) at.push(i); });
assert.equal(at.length, 1, `CHANGELOG.md must hold the "${CHANGELOG_TITLE}" section exactly once (found ${at.length})`);
let end = lines.length;
for (let i = at[0] + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
return lines.slice(at[0] + 1, end);
}
/** The bullets of one `### ` subsection of the round's CHANGELOG section, each flattened to one line. */
function changelogBullets(heading) {
const lines = changelogSection();
const s = lines.findIndex((l) => l.replace(/\s+$/, '') === heading);
assert.ok(s !== -1, `the "${CHANGELOG_TITLE}" section of CHANGELOG.md must have "${heading}"`);
const out = [];
for (const l of lines.slice(s + 1)) {
if (/^### /.test(l)) break;
if (/^- /.test(l)) out.push(l);
else if (out.length && l.trim() !== '' && /^\s/.test(l)) out[out.length - 1] += ` ${l}`;
else if (l.trim() !== '') out.push('');
}
return out.filter((b) => b !== '').map(flat);
}
// The review's two MAJOR findings part 3 leaves open, and the two it fixes; the
// ids are the review's, written out here.
const OPEN_IDS = ['3b98d18b', '69b086eb'];
const FIXED_IDS = ['87a693f0', '021996c6'];
test('[KI3] the CHANGELOG lists part 3\'s fixes, the review\'s other two MAJOR and the corrected tokenizer line', () => {
const known = changelogBullets('### Known issues');
// Red: each open finding has exactly one Known-issues bullet, and that bullet names decision 89.
for (const id of OPEN_IDS) {
const hits = known.filter((b) => b.includes(id));
assert.equal(hits.length, 1, `### Known issues must hold exactly one bullet naming ${id}; found ${hits.length}`);
assert.ok(hits[0].includes('decision 89'), `the Known-issues bullet naming ${id} does not say decision 89: ${hits[0]}`);
}
// Red: the typed arguments the guard now refuses have one Known-issues bullet.
const refused = known.filter((b) => b.includes("<<$'\\t'"));
assert.equal(refused.length, 1, `### Known issues must hold exactly one bullet naming the typed argument <<$'\\t'; found ${refused.length}`);
// Red: the tokenizer bullet says what is left and when the operator accepted it.
const tok = known.filter((b) => b.includes("'90s"));
assert.equal(tok.length, 1, `### Known issues must hold exactly one bullet holding '90s; found ${tok.length}`);
for (const part of ['say "hi"', 'x --since ', '2026-10-27', '2026-10-28']) {
assert.ok(tok[0].includes(part), `the tokenizer Known-issues bullet must hold ${JSON.stringify(part)}: ${tok[0]}`);
}
for (const gone of ['"a "', 'accepted these']) {
assert.ok(!tok[0].includes(gone), `the tokenizer Known-issues bullet must no longer hold ${JSON.stringify(gone)}: ${tok[0]}`);
}
// Red elsewhere: one ### Fixed bullet per part-3 id, each naming its review
// finding, and a door for the round.
const fixed = changelogBullets('### Fixed');
for (const [tag, id] of [['(CQW)', '87a693f0'], ['(PQO)', '021996c6']]) {
const hits = fixed.filter((b) => b.includes(tag));
assert.equal(hits.length, 1, `### Fixed must hold exactly one bullet marked ${tag}; found ${hits.length}`);
assert.ok(hits[0].includes(id), `the ### Fixed bullet marked ${tag} must name review finding ${id}: ${hits[0]}`);
}
const doors = changelogBullets('### Doors').filter((b) => b.includes('decision 89'));
assert.equal(doors.length, 1, `### Doors must hold one bullet naming decision 89; found ${doors.length}`);
// Control: no fixed finding is listed as open.
for (const id of FIXED_IDS) {
assert.deepEqual(known.filter((b) => b.includes(id)), [], `Control: the fixed finding ${id} must not stand under ### Known issues`);
}
});

View file

@ -0,0 +1,124 @@
// tests/lib/fix-round-4-5b-part-3-scripts.test.mjs
// The form of fix round 4-5b part 3's three shell scripts (plan
// .claude/projects/2026-10-28-fix-round-4-5b-part-3, Step 4c): the red
// wrapper, the regression script and the end-to-end run. Untagged on purpose
// and listed in no red wrapper's FILES: these checks are not a part-3 fix.
// - each script exists, is ASCII only, passes /bin/bash -n and uses no
// bash 4 form;
// - the red wrapper hands the shared red script exactly one --other-ids,
// whose words are the 4-5a IDS= words, then part 1's, then part 2's, and
// --ids CQW PQO KI3 (run from a temp root next to a stub red-round.sh
// that prints its arguments, the method of tests/lib/red-script.test.mjs);
// - with the 4-5a, the part-1 or the part-2 wrapper lacking its IDS= line,
// the red wrapper exits 2 with a setup failure and never runs the shared
// script.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const RED = 'tests/fix-round-4-5b-part-3-red.sh';
const REGRESSION = 'tests/fix-round-4-5b-part-3-regression.sh';
const E2E = 'tests/e2e-fix-round-4-5b-part-3.sh';
const WRAP_A = 'tests/fix-round-4-5a-red.sh';
const WRAP_B = 'tests/fix-round-4-5b-red.sh';
const WRAP_C = 'tests/fix-round-4-5b-part-2-red.sh';
const PART3_IDS = ['CQW', 'PQO', 'KI3'];
/** A repo file's bytes; the file must exist. */
function bytes(rel) {
assert.ok(existsSync(join(ROOT, rel)), `missing file ${rel}`);
return readFileSync(join(ROOT, rel));
}
/** The words of a wrapper's one IDS='...' line. */
function idsOf(rel) {
const lines = bytes(rel).toString('utf8').split('\n').filter((l) => l.startsWith('IDS='));
assert.equal(lines.length, 1, `expected one IDS= line in ${rel}; found ${lines.length}`);
const m = lines[0].match(/^IDS='([^']*)'$/);
assert.ok(m, `the IDS= line of ${rel} is not one single-quoted list: ${lines[0]}`);
return m[1].split(/\s+/).filter(Boolean);
}
test('part-3 scripts: each exists, is ASCII only, passes /bin/bash -n and uses no bash 4 form', () => {
for (const rel of [RED, REGRESSION, E2E]) {
const buf = bytes(rel);
assert.equal(buf.findIndex((b) => b > 127), -1, `${rel} is not ASCII only`);
const r = spawnSync('/bin/bash', ['-n', join(ROOT, rel)], { encoding: 'utf8', env: childEnv() });
assert.equal(r.status, 0, `${rel} does not pass /bin/bash -n: ${r.stderr}`);
const text = buf.toString('utf8');
for (const bash4 of [/\bdeclare -A\b/, /\bmapfile\b/, /\breadarray\b/, /\|&/, /^\s*[A-Za-z_]+=\(/m,
/\$\{[A-Za-z_]+,,\}/, /\$\{[A-Za-z_]+\^\^\}/]) {
assert.doesNotMatch(text, bash4, `${rel} uses ${bash4}, which bash 3.2 lacks`);
}
}
});
/** A stand-in for the shared script: one ARG: line per argument, exit 0. */
const STUB = `#!/bin/bash
for a in "$@"; do printf 'ARG:%s\\n' "$a"; done
exit 0
`;
/** A temp root with the part-3 wrapper, the three older wrappers and the stub as tests/helpers/red-round.sh. */
function stubRoot(base) {
const dir = join(base, 'root');
mkdirSync(join(dir, 'tests', 'helpers'), { recursive: true });
for (const rel of [RED, WRAP_A, WRAP_B, WRAP_C]) writeFileSync(join(dir, rel), bytes(rel));
writeFileSync(join(dir, 'tests', 'helpers', 'red-round.sh'), STUB, { mode: 0o755 });
return dir;
}
/** The part-3 wrapper run from a temp root: status, output and the arguments the stub saw. */
function runWrapper(dir) {
const r = spawnSync('/bin/bash', [join(dir, RED)], { cwd: tmpdir(), encoding: 'utf8', timeout: 30000, env: childEnv() });
const argv = (r.stdout || '').split('\n').filter((l) => l.startsWith('ARG:')).map((l) => l.slice(4));
return { status: r.status, out: `${r.stdout}${r.stderr}`, stderr: r.stderr || '', argv };
}
test('part-3 scripts: the red wrapper passes one --other-ids, the 4-5a, part-1 and part-2 ids in order, and its own three ids', () => {
const text = bytes(RED).toString('utf8');
const execLines = text.split('\n').filter((l) => /^exec /.test(l));
assert.equal(execLines.length, 1, `${RED} must hold one exec line; found ${execLines.length}`);
assert.equal(execLines[0].split('--other-ids').length - 1, 1, `${RED}: the exec line must pass --other-ids exactly once: ${execLines[0]}`);
const base = mkdtempSync(join(tmpdir(), 'part3-red-wrap-'));
try {
const r = runWrapper(stubRoot(base));
assert.equal(r.status, 0, `${RED} did not run the shared script:\n${r.out}`);
const at = r.argv.map((a, i) => (a === '--other-ids' ? i : -1)).filter((i) => i >= 0);
assert.equal(at.length, 1, `${RED} handed the shared script --other-ids ${at.length} times:\n${r.out}`);
const other = (r.argv[at[0] + 1] || '').split(/\s+/).filter(Boolean);
assert.deepEqual(other, [...idsOf(WRAP_A), ...idsOf(WRAP_B), ...idsOf(WRAP_C)],
`${RED}: --other-ids is not the IDS= words of ${WRAP_A}, then ${WRAP_B}, then ${WRAP_C}\n${r.out}`);
const idsAt = r.argv.indexOf('--ids');
assert.ok(idsAt >= 0, `${RED} passed no --ids:\n${r.out}`);
assert.deepEqual(r.argv[idsAt + 1].split(/\s+/).filter(Boolean), PART3_IDS, `${RED}: --ids is not ${PART3_IDS.join(' ')}\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
});
test('part-3 scripts: the red wrapper exits 2 with a setup failure when an older wrapper has no IDS= line', () => {
for (const missing of [WRAP_A, WRAP_B, WRAP_C]) {
const base = mkdtempSync(join(tmpdir(), 'part3-red-noids-'));
try {
const dir = stubRoot(base);
const text = bytes(missing).toString('utf8');
writeFileSync(join(dir, missing), text.split('\n').filter((l) => !l.startsWith('IDS=')).join('\n'));
const r = runWrapper(dir);
assert.equal(r.status, 2, `${RED}: ${missing} without an IDS= line is not exit 2\n${r.out}`);
assert.match(r.stderr, /setup failure/, `${RED}: no setup failure line when ${missing} has no IDS= line\n${r.out}`);
assert.deepEqual(r.argv, [], `${RED} ran the shared script though ${missing} has no IDS= line\n${r.out}`);
} finally {
rmSync(base, { recursive: true, force: true });
}
}
});