fix(#4276): read the quoting, not just the digits, before eating an IO number (#4420)

POSIX recognizes an IO number only when the digit run is unquoted, but
tokenize()'s redirection branch tested `cur` — the token's characters — and
never `curMask`, their quoting. `"2"` and `2` were indistinguishable to it,
so a correct `gsd_run query commit ... --files "2">out` had its value
consumed as an IO number and scored as an unscoped invocation: the #2269
guard reddening on documentation that was right.

The mask was already maintained in the same loop and thrown away on this
branch. One conjunct reads it: '0' marks a bare character, so /^0+$/ asks
precisely whether every character of the digit run was unquoted.

The regression arms come in both directions. The quoted rows (glued,
detached, single-quoted, and the partially-quoted 2"3" that a
some-character-unquoted test would get wrong) must be scoped; the existing
unquoted `--files 2>&1` row is the negative control and must stay unscoped,
so an implementation that simply stopped consuming IO numbers altogether
fails instead of passing.

No live instance exists in any of the six scan roots, so this is latent
rather than urgent — a false positive, never a silent miss.

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
Michel Moreira
2026-09-07 12:47:43 -03:00
committed by GitHub
parent 476394689a
commit 93abf3a7b6
2 changed files with 30 additions and 1 deletions

View File

@@ -1573,6 +1573,12 @@ describe('workflow call sites declare --files (#2269)', () => {
// vouch as a value.
'gsd_run query commit "docs: revert" --files >/dev/null 2>&1 || true',
'gsd_run query commit "docs: plan" --files > out.md',
// #4276 NEGATIVE CONTROL. Here the 2 is genuinely an IO number: the
// shell consumes `2>&1` whole and --files reaches argv with no value.
// This row is what stops the quoted-digit fix below from over-
// correcting into "stop consuming IO numbers" — an implementation that
// simply dropped the redirection branch would satisfy the scoped rows
// and fail here.
'gsd_run query commit "docs: plan" --files 2>&1',
// An unquoted # begins a comment: everything after it, --files included,
// never reaches argv.
@@ -1612,6 +1618,20 @@ describe('workflow call sites declare --files (#2269)', () => {
'gsd_run query commit "docs: ship phase 4 — PR #42 [ci skip]" --files .planning/STATE.md',
// Mid-word # is literal too, as in the shell.
'gsd_run query commit docs:PR#42 --files .planning/STATE.md',
// #4276: a QUOTED digit run is not an IO number. POSIX recognizes one
// only when the digits are bare, so the shell passes `"2"` as an
// ordinary argument and this invocation IS scoped — on a file named
// `2`, which is legal. Before the fix the tokenizer read the token's
// characters without its quoting, ate the value as an IO number, and
// reported a correct line as unscoped.
'gsd_run query commit "docs: plan" --files "2">out',
// Detached from the operator, and single-quoted, so neither the gluing
// nor the quote style is what carries the fix.
'gsd_run query commit "docs: plan" --files \'2\' > out',
// Partially quoted: `2"3"` is not a bare digit run either, and a mask
// test that asked "any character unquoted?" instead of "every
// character unquoted?" would get this one wrong.
'gsd_run query commit "docs: plan" --files 2"3">out',
];
for (const line of scoped) {
assert.ok(invocationCandidates(line).length > 0, `should be an invocation: ${line}`);

View File

@@ -146,8 +146,17 @@ const tokenize = (str) => {
// Redirections: the operator and its target are consumed by the shell,
// not passed as arguments. A glued all-digit word is an IO number
// (`2>&1`) and belongs to the redirection, not to argv.
//
// #4276: POSIX recognizes an IO number only when the digit run is
// UNQUOTED, so the test has to read the quoting and not just the
// characters. `cur` has already lost that distinction — `"2"` and `2`
// reach here identically — and `curMask` is where it survives ('0' marks
// a bare character). Without the mask conjunct a correct
// `--files "2">out` had its value eaten as an IO number and scored as an
// unscoped invocation: a false positive on documentation that was right.
if (ch === '>' || ch === '<') {
if (started && /^[0-9]+$/.test(cur)) { cur = ''; curMask = ''; started = false; } else { flush(i); }
const bareDigitRun = /^[0-9]+$/.test(cur) && /^0+$/.test(curMask);
if (started && bareDigitRun) { cur = ''; curMask = ''; started = false; } else { flush(i); }
let j = i + 1;
if (str[j] === ch) j += 1; // >> / <<
if (str[j] === '&') j += 1; // >& (2>&1)