SIGN IN SIGN UP

combo: numeric fds (#326) + loop redirections (#329) + group stdin ownership (#330), pre-resolved (#336)

* feat(interpreter): support numeric file descriptors (exec N< file, read -u N, done N< file, <&N)

User descriptors (fd >= 3) now live in a real descriptor table instead of
being ignored or half-modelled per call site.

- `src/interpreter/fd-table.ts` owns the encoding of
  `ctx.state.fileDescriptors` (kept a `Map<number, string>` because it is
  part of the public CommandContext surface) and exposes typed entries:
  input / output / readwrite / dup-in / dup-out, plus read, advance,
  snapshot and restore.
- `src/interpreter/numeric-fd-redirects.ts` applies every redirection that
  names a descriptor by number — `N< file`, `N> file`, `N>> file`,
  `N<> file`, `N<<EOF`, `N<<<word`, `N<&M`, `N>&M`, `N<&M-`, `N>&M-`,
  `N<&-`, `N>&-` — for simple commands, `exec`, all compound commands and
  function calls, and returns the closure that takes them back down again.

Reading is consuming: an input descriptor keeps only its unread remainder,
so successive `read -u N` / `read <&N` calls advance one shared position.
`exec` keeps its descriptors; every other construct gets them for the
duration of that command only, restoring the enclosing descriptor and its
position afterwards.

`applyRedirections` no longer treats `<&N` / `3>&1` as stdout/stderr
routing, resolves `>&N` through the typed table (fixing `exec N>> file`,
which reported a bad descriptor), and reports a bad descriptor when a
script writes to a read-side descriptor.

* test(interpreter): cover numeric file descriptors

- `fd-table.test.ts` (24): encoding round-trips, read/advance semantics,
  descriptor-limit accounting, snapshot/restore.
- `numeric-fds.input.test.ts` (20): the three repros from #321, `read -u N`
  on unopened / write-only descriptors, `<&N`, `exec 3<&0`, closing.
- `numeric-fds.output.test.ts` (12): `exec N>`, `exec N>>`, `>&N`,
  command-scoped `N> file`, `exec N>&M-`, descriptor limit.
- `numeric-fds.scoping.test.ts` (13): `done N< file` on while/until/for/if/
  case/group/subshell, function calls and definitions, nested reuse of the
  same number, loop stdin vs. descriptor independence.
- `numeric-fds.comparison.test.ts` (20) with fixtures recorded from real
  bash (GNU bash 3.2.57(1)-release, /bin/bash on macOS).

* chore: add changeset for numeric file descriptors

* fix(interpreter): close scoped descriptors on the empty-command-name path

Three early returns in executeSimpleCommandInner — the paths taken when
the command word expands to nothing — returned without undoing the
command's numeric-fd redirections, so `x=''; $x 3< f` leaked descriptor 3
into the rest of the script.

Also in this change:

- File content is no longer mistaken for a table marker. The descriptor
  table stores strings, so a file whose first line reads `__file__:/tmp/x`
  used to decode as an output descriptor: `read -u 3` failed with a bad
  descriptor, and `echo BOOM >&3` (wrong before this branch too) wrote
  into the path spelled inside the file. `state.inputFds` records which
  descriptors hold verbatim content, and travels with the value through
  duplication, scoped save/restore and the subshell state copy.
- The last raw-marker writers now go through the table: the `{var}>file`
  allocator and the fd 0/1/2 duplication branches in applyRedirections
  and `exec` build typed entries and hand them to setFdEntry / dupFd.
- `read -u 1` / `read -u 2` report bash's wording for an open but
  write-only descriptor ("read error: N: Bad file descriptor") rather
  than the not-open wording.

* test(interpreter): cover fd leaks, marker-shaped content, locked fixtures

- Four scoping tests for the empty-command-name paths (`x=''; $x 3< f`,
  `x=''; $x true 3< f`, `'' 3< f`, `x=''; $x true 4> o.txt`).
- Four input tests for file content shaped like a table marker: read back
  verbatim, refused for writing without touching the named path, and
  preserved through duplication and scoped reuse.
- fd-table tests for the content/marker classification and for carrying it
  through dupFd and snapshot/restore.
- `read -u 1` / `read -u 2` wording.
- The descriptor-limit test now asserts full stdout/stderr and the exact
  exit code instead of `not.toBe(0)` + `toContain`.
- Three comparison fixtures are marked `"locked": true`: they are the
  cases whose bash run writes a diagnostic to stderr, where only the argv0
  and `line N:` prefix differ between bash 3.2 and 5.x. The compared
  fields are identical, but an unlocked re-record on the CI runner would
  rewrite the file and fail the workflow's no-diff check.

* test(interpreter): split fd test files under the 300-line guideline

`fd-table.test.ts` (323) and `numeric-fds.input.test.ts` (364) had grown
past the repo's 300-line limit for test files. Split by theme, no
assertions changed:

- fd-table.encoding.test.ts   — encoding round-trips, content vs. marker
- fd-table.access.test.ts     — accessors, read/advance, snapshot/restore
- numeric-fds.marker-content.test.ts — marker-shaped file content, lifted
  out of numeric-fds.input.test.ts

* fix(interpreter): share one read offset between duplicated descriptors

`N<&M` duplicates the descriptor, not the file: both names refer to one
open file description and share a single read offset. The table copied the
value instead, so `exec 3< f; exec 4<&3; read -u 3 a; read -u 4 b` gave
both `a` and `b` the FIRST line where bash gives `b` the second.

`state.fdAliases` records which descriptors share a description; every
member maps to the same Set, and a descriptor with no aliases has no
entry. advanceFd moves the whole group, so a read through any member is a
read through all of them.

The surrounding rules fall out of the same structure:

- Opening a descriptor gives it a fresh description, so setRawFd leaves
  the group first: `exec 4<&3; exec 4< other` no longer moves fd 3.
- advanceFd writes through a private path that does NOT leave the group —
  a read must not break the aliasing it is moving.
- closeFd drops the member; the last one left stops aliasing.
- restoreFds puts the descriptor back and re-attaches it to whichever
  co-member is still open, but never rewinds the offset — after
  `{ read -u 4; } 4<&3` bash leaves fd 3 where the body's read left it.
- beginIsolatedShellState clones the groups preserving their sharing
  structure, so a subshell's reads stay self-consistent.

Verified against bash across eleven shapes: the repro above, close-one-
read-the-other, a chain of duplicates, re-open, a move (`4<&3-`), and
command-, group- and loop-scoped dups including one over an already-open
descriptor. All match except the subshell boundary, which copies the
table — the pre-existing deviation already noted in the PR body.

* fix(interpreter): apply output redirections on while/until loops

`while true; do echo x; break; done >/dev/null` printed `x` to the caller:
`executeWhile` walked its redirection list only to pull input redirections
into groupStdin and never called preOpenOutputRedirects/applyRedirections,
so every `>`, `>>`, `2>`, `2>&1`, `&>` and `>|` on a loop was dropped.
`executeUntil` ignored `node.redirections` entirely. `for`, C-style `for`
and `case` already had the wiring.

Extract the prologue into prepareLoopRedirections(): consume the input
redirections into the loop's stdin first (so a failing `< file` aborts
before a later `> file` is truncated, and so `<<<` keeps bash's no-glob
semantics), then hand the rest to preOpenOutputRedirects before the loop
and applyRedirections after it — the same pair `for`/`case` use. `until`
also takes the pipeline stdin `while` already accepted, so a piped or
`< file`-fed until loop reads its input.

Make stdin ownership explicit with resolveLoopStdin(). The loops used to
restore groupStdin unconditionally, rewinding a stream they had merely
inherited, so `printf 'a\nb\n' | { while read x; do break; done; read y; }`
re-read line 1. A loop now installs and restores groupStdin only when it
owns the stream — its own input redirection or its pipeline stdin — and
leaves an inherited one alone so reads advance it. An empty own redirect
still counts as ownership, so `done < empty` no longer eats the enclosing
stream.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DayCPuYZEmv4VszJXTHT3
Signed-off-by: Lars Trieloff <lars@trieloff.net>

* fix(interpreter): groups no longer rewind stdin they never replaced

A command group put the shell's stdin read position back even when it
never replaced it, discarding everything its body had read:

    { { read a; }; read b; echo "b=[$b]"; } < two-line-file
    # bash: b=[L2]   just-bash: b=[L1]

The inner group has no redirection of its own, so it shares the shell's
fd 0 and the reads inside it move the one shared position. The same
restore runs function bodies (a function body is a group) and `eval`,
where it was worse: `eval` also installed an empty stdin over the
shell's before running its body, so `{ eval 'while read l; ...'; } < file`
printed nothing at all.

A construct now restores only what it actually replaced. A group with
its own fd 0 -- `< file`, a heredoc or here-string on the group, a pipe
into it -- hands the enclosing position back untouched; one without lets
the body's reads advance it.

Ownership cannot be inferred from the content, because `f < empty-file`
and an unredirected `f` both reach the callee as "" while meaning
opposite things -- EOF versus "inherit the shell's stdin". Guessing
breaks the shield idiom: `while read x; do f < /dev/null; done < file`
would run three times instead of five. So the fact travels beside the
string: executeSimpleCommandInner passes `stdinRedirected` through
runCommand and dispatchBuiltin to handleEval and callFunction, which
combines it with the function's own definition-level redirections and
hands `stdinOwned` to executeCommand -> executeGroup/executeSubshell.
The wrappers that re-enter dispatch with the same stdin -- `command`,
`builtin`, `exec` -- forward it too. Every new parameter is optional and
defaults to false.

Both restore points keep one guard: `undefined` is not a read position,
so a body that cleared shared stdin it does not own (pipeline stages do,
see #328) gets the inherited position restored rather than propagated.

Same root pattern as #328, which fixes the pipeline side in
pipeline-execution.ts; the two compose. 90 shapes were compared against
GNU bash 3.2.57: 26 fixed, 64 unchanged, none regressed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DayCPuYZEmv4VszJXTHT3
Signed-off-by: Lars Trieloff <lars@trieloff.net>

* fix(interpreter): process loop redirections strictly left to right

Reported by the Vercel review bot on control-flow.ts:134: a failing `<`
input redirect aborted before any output redirect had been pre-opened, so
an output redirect standing to its LEFT was never applied. Bash walks the
list in order, so `done > out < nosuch` truncates `out` and then fails,
while `done < nosuch > out` fails first and leaves `out` alone — the
branch only got the second one right.

prepareLoopRedirections now makes a single ordered pass. Output redirects
accumulate into a pending run that is pre-opened as soon as an input
redirect (or the end of the list) is reached; an input redirect resolves
where it stands and, on failure, returns its diagnostic through exactly
the redirections opened so far — which is how `done 2> err < nosuch`
lands the message in `err` like bash does. `ExpandedRedirectTargets` is
keyed by position in the array handed to preOpenOutputRedirects, so each
run's keys are rebased onto the accumulated list before merging and
`done > "f$((n++))" < nosuch` still expands its target exactly once.

Also fixes `done > f < f`, which now truncates before reading and so sees
an empty file, matching bash. Measured identical on bash 3.2.57 and
5.3.15. `for`/`case` keep the old all-then-truncate ordering; that is
documented, not changed here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DayCPuYZEmv4VszJXTHT3
Signed-off-by: Lars Trieloff <lars@trieloff.net>

---------

Signed-off-by: Lars Trieloff <lars@trieloff.net>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
L
Lars Trieloff committed
d97425dff8f51cfd773d22bc009561a09235cd1b
Parent: 6680247
Committed by GitHub <noreply@github.com> on 8/6/2026, 2:07:37 AM