%% GROUP Recorded from your messages

%% GROUP Shape of the module
These items decide what kind of program `ShellSubsetLarge.pm` is. A1 and A2 are implemented; A3 and A4 as their alternative A (my choice, awaiting yours); A5 is implemented in the executor; A6 is settled (see R2). A2, A3, A4 and A6 offer alternatives, named by letters (alternative A is not item A1).

%% GROUP POSIX features to add
Each item adds one family of constructs that dash and bash both have, in the forms where they behave alike (checked with dash 0.5.12 and `bash --posix` 5.2.21, the shells on this machine).

**Measured** counts the samples of the corpora that the present parser rejects, that lie within the POSIX ceiling (both shells accept their syntax, and no bash-only construct was found in them), and that use the feature. The seven numbers are, in this order: tldr / NL2Bash / bash Cookbook / pure-sh-bible / pure-bash-bible / ShellCheck clean / ShellCheck warned. A sample can use several features. Details: `claude/large-subset-investigation.md`, section 3.

**Cost** is my estimate of the lines the item adds, in the dense style of `ShellSubset.pm`; S1 adds them up; they are kept for comparison, the actual parser being 258 lines. Status (2026-11-02): all F items are implemented in the parser (`claude/ShellSubsetLarge.pm`) and in the executor (`claude/pspawn-ShellSpawn.pm` with `claude/pspawn-ShellSpawn-Large.pm`), except F12's `pwd` as a builtin (dropped: `ShellSubset`, which is asked first, already accepts `pwd` and runs it as `/bin/pwd`, so the large grammar never sees it). The executor's remaining differences from dash are in `claude/large-subset-upstream-reports.md` (L6–L8).

Options of the present parser (spec section 9): `if`, `while`, `for`, `case`, `read`, `assign` and `param_ops` are always on in the large subset. The option `brace` (brace expansion, V13) is dropped: brace expansion is bash-only and waits for T1. These stay as they are: `nocaret` (reject `^`, C1), `nopwd` (reject `$PWD`, C4), `check` (the caller's locale checker, C4), `empty` (accept an empty string, D5), `fdmulti` (descriptor numbers of several digits, D4), `tilde_args` (`~` after `NAME=` in arguments, E4) and `redir_strict` (Q1).

%% GROUP What stays with sh
One item: what the large subset would still refuse. Written in the string, it goes to `/bin/sh -c`; showing up only at run time, it stops the shell with status 248 (A4).

%% GROUP Later
One item: the bash-only constructs, as a separate later step.

%% GROUP Size, order of work, and a bug
The budget, the order of work, and one bug found in the existing parsers (U1).

@@ R1 | Recorded from your messages | Goal | Make `ShellSubsetLarge.pm` accept as much of sh as it can, as long as the code does not grow unreasonably
## now
Your message: `ShellSubset.pm` was meant to define and implement easy, low-hanging fruit; you asked me to investigate a changed goal: a module `ShellSubsetLarge.pm` that implements as much as possible, provided the code size does not blow up unreasonably.

## plan
This page is the result of that investigation. The findings, measurements and size estimates are in `claude/large-subset-investigation.md`. Nothing of the module is written yet. `ShellSubset.pm` stays as it is (apart from the bug fix U1).

## nogo
Not applicable: this is your request. Write in a note if I misread it.

## refs
`claude/large-subset-investigation.md`.

@@ R2 | Recorded from your messages | Design | Implement the large subset as an addition to `ShellSubset.pm`, which is asked first
## now
Your message of 2026-11-01: “Can you start implementing this as an addition to `ShellSubset.pm`? With, say, it calling our parser only on a misparse? If so, go for it. (Do not worry about `fork()`: another session already implemented (?) execution of parse trees via `posix_spawn()`.)”

## plan
Done for the parser. `ShellSubsetLarge::parse` first calls `ShellSubset::parse`. Only when that rejects the string does it parse the string again, using `ShellSubset`'s own code with eight of its subs and two of its tables (`%BI`, the builtins it rejects, and `%SH`, the names only a shell sets) temporarily replaced (`local`) for that call.

This needed a small patch to `ShellSubset.pm`, `claude/large-subset-shellsubset.patch`, applied by the posix_spawn session on 2026-11-01. In it, `ShellSubset`'s ten lookup tables and regexes (`%KW`, `%BI`, `%SH`, `$NM`, …) become `our` variables, so that `ShellSubsetLarge` can use them and swap two of them with `local`; `ShellSubset`'s behaviour does not change from that. The patch also fixes five bugs of `ShellSubset` found on the way (U1 and four more, listed in `claude/large-subset-upstream-reports.md`).

A6 (how to run Perl-side parts of a pipeline, such as a function or a `while read` loop, without `fork`) is settled by the existing `posix_spawn` executor, `claude/pspawn-ShellSpawn.pm`: it already runs groups, loops and subshells in the Perl process without `fork`, as continuations driven by one event loop (A6's alternative B). Functions can run the same way. Since the evening of 2026-11-01 that executor also runs `ShellSubsetLarge` trees; per your decision in the posix_spawn session, the caller passes the parser (`run(STRING, parser => CODE)`), by default the largest one already loaded.

## nogo
Not applicable: this is your decision. Write in a note if I misread it.

## refs
`claude/ShellSubsetLarge.pm`, `claude/large-subset-spec.md`, `claude/large-subset-shellsubset.patch`.

@@ R3 | Recorded from your messages | Name | Name the module `ShellSubsetLarge.pm`
## now
Your message of 2026-11-01: the name `ShellLargeSubset.pm` is not consistent; it should be `ShellSubsetLarge.pm`.

## plan
Done: the module and its files use this name, and so does this page.

## nogo
Not applicable: this is your decision.

## refs
`claude/ShellSubsetLarge.pm`.

@@ A1 | Shape of the module | Design | Build `ShellSubsetLarge` as an interpreter: parse the whole string first, then decide some things at run time
## now
`ShellSubset.pm` decides everything when it parses. It rejects every construct whose effect cannot be pinned down from the text alone, and a rejected string goes to `/bin/sh -c` as a whole. Examples of what it rejects for that reason: `"$EDITOR" file` (the command name comes from a variable; spec rule V3), `f() { echo "$1"; }; f x` (a function and a positional parameter), `a; exec b` (spec rule B3: `exec` only where nothing can follow it).

Functions and `$1` are the largest gaps in scripts; command names from expansions follow (investigation, section 3).

## plan
The module still parses the whole string before running anything. A syntax error, or a construct outside the large subset that is visible in the text, still rejects the whole string, which then goes to `/bin/sh -c` as today.

What the text cannot tell is decided when it runs. Example: in `cmd=ls; $cmd -d /` the parser accepts `$cmd` as a command name; at run time it is `ls`, which runs as a program and prints `/`.

The features that need this are F1 (functions, `$1`), F3 (command names from expansions, `exec` anywhere) and F7 (`eval`, `.`). Spec rules that then go:

- V3 (literal command name) and B3 (place of `exec`): F3.
- V19: `! exec cmd` is accepted; as in both shells, the `!` is lost and the status is cmd's.
- V5: settings before `:` and `exit` are accepted; `a=2 :` keeps `a=2` afterwards, as in both shells.
- the ban on `$1`…`$9`, `$#`, `$@`, `$*`: F1.

V7 stays, except that `exec` with only redirections becomes allowed (F3).

## nogo
Everything stays decided at parse time. F1, F3 and F7 then count as No-go. The other F items stay possible, limited to what the text shows: a literal trap action (F8), a literal name after `command` (F12). Most of the measured gain in scripts disappears.

## refs
New `claude/ShellSubsetLarge.pm`; new spec `claude/large-subset-spec.md`.

@@ A2 | Shape of the module | Design | Make the large module's parse tree a superset of `ShellSubset`'s, so that one executor runs both
## now
No executor exists yet. `ShellSubset` returns the tree described in section 8 of `claude/shell-subset-spec.md`. Example: `LANG=C sort <in.txt` gives `[[[{env => ['LANG','C'], argv => ['sort'], redir => [[0,'<','in.txt']]}]]]`.

## plan
For text that both modules accept, `ShellSubsetLarge` returns the identical tree; for the example above, the same structure. New node kinds appear only for new constructs, for example a function definition or a here-document (their exact form goes into the new spec).

Then the executor is written once, for the large tree, and a `ShellSubset` tree runs on it. Where the large rules differ from `ShellSubset`'s (F11: the executor expands patterns itself, where spec rule E3 leaves them to the caller; F12: `pwd` as a builtin, where spec rule B2 runs `/bin/pwd`), the executor has an option that keeps `ShellSubset`'s rule. `ShellSubset.pm` and its 816 tests stay; they keep defining the small subset.

## nogo
Say in a note which instead: **B**, two different trees, with an executor for each; or **C**, retire `ShellSubset.pm` and keep only the large module. (**A** is this plan.)

## refs
Spec section 8; the new spec.

@@ A3 | Shape of the module | Dialect | Keep the present rule for the large subset: accept only what dash and bash run alike
## now
Section 1 of `claude/shell-subset-spec.md`: where dash and bash differ, the construct is rejected unless the spec says otherwise. Example: `[[ -n $x ]] && echo y` is rejected (bash only).

## plan
The same rule for the large subset (alternative A below). Bash-only constructs (`[[`, `((`, `$'…'`, arrays, `${x/a/b}`, `<<<`, `<(…)`, ...) stay rejected; T1 keeps them for a later step.

Alternatives:

- **A**: only what dash and bash run alike (this plan).
- **B**: follow whichever shell `/bin/sh` is on the machine, found once when the module loads; bash-only syntax is then accepted where `/bin/sh` is bash.
- **C**: the caller chooses dash or bash for each call.

Measured, for all one-liners together: 95.6% accepted today; at most 97.4% under A; at most 99.3% under B with `/bin/sh` being bash, or under C with bash chosen (this figure was measured with plain `bash -n`). For bash-flavoured scripts the difference is much larger (bash Cookbook: at most 43% under A, 95% with bash).

## nogo
Write B or C in a note. That brings the bash tier (T1) into this round, at about 400–500 more lines, whatever T1 is marked.

## refs
Spec section 1; investigation, sections 3 and 4.

@@ A4 | Shape of the module | Design | When an unsupported construct only shows up at run time, stop with status 248 and a message
## now
There is no such case today: the parser sees all the text before anything runs, so a rejected string goes to sh as a whole.

## plan
(Rewritten after your note of 2026-11-02, "2 is too common. What about 250-2=248?": the status is 248, documented in the large spec, section 5, "Exit statuses of the module itself".)

With A1, F3, F7 and F8 some text is seen only at run time, after earlier commands have already run: the argument of `eval`, a file read by `.`, a trap action that comes from a variable, a command name that comes from an expansion. The whole string can then no longer be handed to sh. In that case the interpreter writes `ShellSubsetLarge: <construct> is not supported` to standard error and stops with status 248, as `exit 2` would at that point: the whole string, or only the enclosing `( … )`, `$( … )`, background chain or element of a pipeline (spec section 4 describes the same for `exit`).

Example: `x='[[ -n y ]]'; echo before; eval "$x"` prints `before`, then the message, and the status is 248. (As `/bin/sh`, bash succeeds; dash prints `[[: not found` and gives 127.)

Another example: `c=ulimit; $c -n` stops with status 248. Both shells run their builtin `ulimit` there (X1 leaves `ulimit` to sh).

Alternatives:

- **A**: this plan.
- **B**: reject at parse time everything whose text or command name is known only at run time: `eval`, `.`, trap actions that are not literal, command names from expansions (also after `command`). Nothing can then show up at run time, as today; F3 and F7 count as No-go.
- **C**: hand the rest of the string to `sh -c`, after exporting the variables and writing the functions out as text. Inexact (the current directory, traps, open descriptors and readonly flags do not carry over cleanly) and much more code.

## nogo
Alternative B, unless a note asks for C.

## refs
New spec; investigation, section 2.

@@ A5 | Shape of the module | Design | Where dash and bash differ only at run time, do what dash does
## now
The spec already follows dash in two places. V6: a redirection error on `:` or `exit` stops the shell with status 2 as in dash (`bash --posix` stops with status 1, plain bash goes on). Section 5, "Arithmetic": an expression that becomes invalid at run time stops with status 2, as in dash (bash: 1).

## plan
The same rule for every difference that cannot be seen in the text. Differences that are visible in the text stay rejected at parse time (A3). Checked here:

- `n=1x; export "$n=2"; echo after`: dash prints `export: 1x: bad variable name` and stops; bash prints `not a valid identifier`, then `after`. The interpreter prints a message and stops with status 2.
- `shift 5` with fewer than 5 arguments: dash prints `can't shift that many` and stops; bash prints an error and goes on. The interpreter stops with status 2.
- `${x:?msg}` with `x` unset: both stop; dash with status 2, bash with 127. The interpreter uses 2.
- `cd` alone with `HOME` unset (now possible, F5): dash stays and succeeds, bash fails. The interpreter stays and succeeds.

## nogo
Follow bash in these cases (print the message and go on; status 127 for `${x:?}`), or name another rule in a note.

## refs
Spec V6 and section 5, "Arithmetic"; investigation, section 5.

@@ A6 | Shape of the module | Executor | Without `fork`, run a second in-process part of a pipeline in a new `perl` process
## now
`Run` is your module for starting processes (latest version in this Project: `Run-0.06.tar.gz-base64.txt`); the executor is to use it to start programs. It has a forkless mode (on by default on Win32, OS/2 and VMS). In a pipeline it can run at most one element that is not a plain program (a group, a chain) in the Perl process itself; others run in forked copies, and are refused in forkless mode. Your design note for `Run` 0.07 plans continuations, driven by an event loop in the one process, instead.

## plan
In the large subset more things are Perl code rather than programs: functions, `while read` loops, `( cd d; make )`. The interpreter keeps the whole shell state in one Perl structure: variables, functions, current directory, umask, open descriptors, traps, positional parameters. It never changes the Perl process's own current directory, umask or descriptors; it applies them only to the programs it starts. Its traps replace the Perl program's signal handlers only while the string runs (F8).

- A subshell `( … )` on its own runs in the same process, on a copy of that structure, which is thrown away afterwards. No fork is needed.
- When two such parts must run at the same time (example: `f | while read l; do echo "<$l>"; done`, with `f` a function), one runs in the process. Each other one runs in a forked copy where `fork` exists; without `fork`, in a new `perl -MShellSubsetLarge` process that gets its part of the tree and the state through a pipe. Cost: about 40 lines.

Alternatives:

- **A**: this plan.
- **B**: make the interpreter resumable, so that `Run` 0.07's event loop can drive several in-process parts in one process. No extra perl processes, but every loop, function call and builtin must be able to pause and resume. My rough guess: the executor about doubles.
- **C**: in forkless mode, reject such strings: at parse time where the text shows it, and with A4 where it only shows at run time.

## nogo
Write B or C in a note.

## refs
`claude/run-executor-investigation.md`; investigation, section 7.

@@ F1 | POSIX features to add | Functions | Add functions, positional parameters, `return`, `shift` and `set --`
## now
`f() { echo "hi $1"; }; f you` is rejected ("function definition"). `$0`…`$9`, `$#`, `$@`, `$*` are rejected everywhere (spec section 5, E1), because in a `system` string they are always empty (and `$0` is always `sh`).

## plan
Accepted:

- function definitions `NAME() BODY`, the body being a group, a subshell or another compound command, with its redirections;
- calls with arguments; inside the function `$1`…`$9`, `${10}`…, `$#`, `$@`, `$*` are its arguments;
- `return [n]` inside a function, and inside a file read with `.` (F7);
- `shift [n]` and `set -- args`;
- `$0`, which is `sh`, as under Perl's `system` (Perl starts `/bin/sh` under the name `sh`).

At the top level `$#` is 0 and `$1` is empty, as in `sh -c`. `"$@"` gives one field per argument, also inside a longer word (`"x$@y"`).

Example: `f() { echo "$#:$1"; }; f a b; echo "$#"` prints `2:a`, then `0` (both shells).

Still rejected: `function f` (bash only); `return` outside a function (dash ends the string silently, bash prints an error); a function whose name is a special builtin.

Measured, positional parameters: 1 / 203 / 15 / 10 / 8 / 43 / 25; function definitions: 3 / 0 / 1 / 11 / 12 / 48 / 28; `shift` and `getopts`: 9 / 4 / 5 / 0 / 0 / 1 / 0. Positional parameters are the largest gap in NL2Bash; function definitions in the two bibles and in the ShellCheck snippets.

Cost: parser about 10 lines, executor about 90 (`"$@"` and `shift` included).

## nogo
Functions and positional parameters stay rejected.

## refs
Spec sections 4 and 5; new spec.

@@ F2 | POSIX features to add | Parameters | Add all POSIX parameter forms: `${x%p}`, `${x%%p}`, `${x#p}`, `${x##p}`, `${x:=w}`, `${x=w}`, `${x:?w}`, `${x?w}`
## now
Accepted: `$x`, `${x}`, `$?`, `$!`; with the option `param_ops` also `${x:-w}`, `${x-w}`, `${x:+w}`, `${x+w}`, `${#x}`. `x=a.b.c; echo ${x%.*}` is rejected ("unsupported parameter form"). Inside the word `w`, quotes and backslashes are rejected (spec section 5).

## plan
All these forms, without an option (the `param_ops` forms included).

Example: `x=a.b.c; echo ${x%.*} ${x##*.}` prints `a.b c` (both shells). With `x` unset, `${x:?msg}` prints `msg` to standard error and stops with status 2 (A5).

Quotes and backslashes inside the word become allowed, with sh's rules. Both shells agree on every case checked:

- `"${x:-'a'}"` gives `'a'` (inside double quotes, single quotes in a `-`, `+`, `=`, `?` word are ordinary characters);
- with `x=abc`, `"${x#'a'}"` and `"${x#"a"}"` give `bc` (in a `%` or `#` word they are quotes);
- `"${x:-"a b"}"`, `${x:-"a  b"}` and `${x:-'a  b'}` give `a b`, `a  b`, `a  b`;
- with `x='a*'`, `"${x%\*}"` and `${x%"*"}` give `a`; `"${x:-a\}b}"` gives `a}b`.

Bash-only forms stay rejected: `${x/a/b}` (dash: "Bad substitution"), `${x:1:2}`, `${!x}`, `${x^^}`.

Measured, `${x%…}` and `${x#…}`: 0 / 11 / 3 / 2 / 1 / 1 / 3; `${x:=…}` and `${x:?…}`: 0 / 1 / 0 / 0 / 0 / 6 / 0.

Cost: parser about 10 lines, executor about 25 (plus the pattern matching, counted in F11).

## nogo
Keep today's set, with the `param_ops` forms behind their option.

## refs
Spec section 5, "Parameters".

@@ F3 | POSIX features to add | Commands | Allow command names that come from an expansion, and `exec` anywhere
## now
Spec rule V3: the command name must be literal, so `"$EDITOR" file` and `$cmd -l` are rejected. Spec rule B3: `exec` only where nothing could follow it, so `a; exec b; c` is rejected. Rule V7: `exec` without a command, with an option, or followed by a builtin name is rejected.

## plan
The command name may be any word. When it runs, its first field decides what happens, in this order: a function; a builtin of this module; a builtin that the module leaves to sh (stop, A4); otherwise a program.

Example: `cmd=ls; $cmd -d /` prints `/`.

`exec cmd …` may stand anywhere: it runs cmd, and then the shell ends with cmd's status. Example: `echo a; exec true; echo c` prints `a` only, with status 0, as in sh.

`exec` with only redirections (`exec >log 2>&1`) becomes allowed: it changes the descriptors of the shell state (A6) for the rest of the string; the Perl program's own descriptors are not touched. `exec` with an option or a builtin name stays rejected (V7).

Measured: 4 / 31 / 10 / 4 / 3 / 21 / 23. (By first error, command names from expansions are 15% of the rejected ShellCheck pieces; `claude/shell-subset-corpus-stats.md`, section 3.)

Cost: parser about 20 lines fewer (the B3 check goes), executor about 10 more.

## nogo
V3 and B3 stay.

## refs
Spec section 4, V3, B3, V7, V19.

@@ F4 | POSIX features to add | Redirections | Add here-documents `<<` and `<<-`
## now
Rejected (spec section 5, "Here-documents", D1).

## plan
Example, with `HOME` set to `/root`:

```
cat <<EOF
$HOME-x
EOF
```

This prints `/root-x` (both shells).

- A quoted delimiter (`<<'EOF'`, `<<"EOF"`, `<<\EOF`) makes the body literal.
- With `<<-`, leading tabs of the body and of the end line are removed.
- In an unquoted body, `$`, `` ` `` and `\` work as inside double quotes, and `"` is an ordinary character.
- Several here-documents on one line are read in order after that line.
- The body reaches the command through a pipe, or a temporary file when it is large.

`<<<` stays rejected (bash only).

Measured: 3 / 3 / 2 / 0 / 0 / 5 / 5.

Cost: parser about 20 lines, executor about 15.

## nogo
Here-documents stay rejected.

## refs
Spec section 5, D1.

@@ F5 | POSIX features to add | Variables | Add `export`, `readonly`, `unset`, `local NAME=value`, and assignments to `IFS`, `HOME`, `PWD`, `OLDPWD`
## now
With the option `assign`, `NAME=value` alone is accepted, except for `HOME`, `CDPATH` and names a shell sets itself. `export`, `readonly`, `unset` and `local` are rejected as builtins. `IFS` cannot be assigned, so word splitting always uses blanks (spec E1).

## plan
Accepted: `export NAME[=value]…`, `readonly NAME[=value]…`, `unset [-v|-f] NAME…`, and inside functions `local NAME=value…`. Assignments alone are accepted without an option.

`IFS` may be assigned; splitting then follows it. Example: `IFS=:; x=a::b; set -- $x; echo $#` prints `3` (both shells).

`HOME`, `PWD` and `OLDPWD` may be read and assigned; `cd` keeps `PWD` and `OLDPWD` up to date (both shells start with `OLDPWD` from the environment and set it on each `cd`). `OPTIND` and `OPTARG` may be used, for `getopts` (F12).

Still rejected:

- `local NAME` without a value. Example: `x=1; f(){ local x; echo "[$x]"; }; f` prints `[1]` in dash (it keeps the outer value) and `[]` in bash.
- `export -p`, `readonly -p`, and `export` or `readonly` alone: they list variables, in different formats.
- `CDPATH` (then `cd` would have to search it), `PS1` (dash sets it to `# ` or `$ `, bash leaves it unset), `PS2`, `PS4`, `_`, `LINENO`, `PPID`, `$-`, and bash's own names (spec section 5, list under E1).

Measured, `export`, `readonly`, `unset`: 15 / 30 / 1 / 0 / 0 / 13 / 14; `local`: 5 / 1 / 0 / 0 / 0 / 7 / 4; `IFS=`: 4 / 4 / 0 / 5 / 0 / 0 / 2.

Cost: executor about 65 lines (IFS splitting included).

## nogo
Assignments alone stay accepted without an option, as the group intro says. These builtins, and assignments to `IFS`, `HOME`, `PWD` and `OLDPWD`, stay rejected.

## refs
Spec sections 4 and 5, option `assign`, E1.

@@ F6 | POSIX features to add | Builtins | Add `set` with the options `-e`, `-u`, `-f`, `-C`, `-a` and their `-o` names
## now
`set` is rejected as a builtin. Measured: 12 / 50 / 2 / 3 / 1 / 29 / 20. The options used, counted over all samples: `-o …` 47 (of which `pipefail` 22, `errexit` 6), `-e` 37, `--` 12, `-f` 6, `-x` 6, `+f` 4, `-v` 3.

## plan
Accepted, each also with `+` to switch it off, and in the long form (`set -o errexit`, `nounset`, `noglob`, `noclobber`, `allexport`):

- `-e`: stop when a command fails, except in the conditions of `if`, `while` and `until`, in all but the last command of a `&&`/`||` chain, and after `!`;
- `-u`: an unset variable is an error and stops the shell (dash "parameter not set", bash "unbound variable");
- `-f`: no file name expansion (F11);
- `-C`: `>` does not overwrite an existing file; `>|` still does;
- `-a`: every assigned variable is exported.

Still rejected: `set` alone and `set -o` alone (they list, in different formats); `-o pipefail` (dash 0.5.12 says "Illegal option -o pipefail"); `-v` (bash then echoes the following lines of a `-c` string, dash does not); `-n`, `-m`, `-b`, `-h`, and the `-o` names of interactive features (`emacs`, `vi`, `history`, ...).

Question about `-x` (print each command to standard error before running it). dash writes `+ echo a b`, bash writes `+ echo 'a b'`.

- **A**: reject `set -x` (it occurs 6 times in all corpora).
- **B**: accept it and print dash's format.

Go means A. For B, write B in a note; the rest of F6 then counts as Go.

Cost: executor about 45 lines, 20 of them for `-e`.

## nogo
`set` stays rejected, except `set --` from F1.

## refs
Spec section 4, builtins; new spec.

@@ F7 | POSIX features to add | Builtins | Add `eval` and `.` (read a file and run it in the current shell)
## now
Both rejected as builtins.

## plan
`eval ARGS…`: the arguments are joined with blanks, parsed by this module and run in the current shell. Example: `eval "echo \$((1+2))"` prints `3`.

`. FILE`: as in both shells, a name without `/` is looked up in `PATH` only (checked: `. zzdot` fails in both when `zzdot` is only in the current directory; `. ./zzdot` works). The file is read, parsed and run in the current shell; `return` ends it. Arguments after FILE are rejected (bash sets `$1`… from them, dash ignores them).

Text that the module does not accept stops with status 248 (A4).

`source` stays rejected: dash 0.5.12 has no `source` ("source: not found").

Measured (`eval` and `.` only, without `source`): 15 / 3 / 0 / 0 / 0 / 8 / 3.

Cost: executor about 12 lines.

## nogo
`eval` and `.` stay rejected.

## refs
Spec section 4, builtins.

@@ F8 | POSIX features to add | Builtins | Add `trap` and `wait`
## now
Both rejected as builtins.

## plan
`trap ACTION SIGNAL…` for `EXIT` and signals by name or number (`INT`, `TERM`, `HUP`, `QUIT`, `USR1`, `USR2`, ...); `trap - SIGNAL` (back to the default); `trap '' SIGNAL` (ignore it; programs started afterwards inherit that). A literal ACTION is checked when the string is parsed; one that comes from an expansion is checked when the trap is set (A4). `wait [PID…]` waits for background chains.

Example: `trap 'echo bye' EXIT; echo hi` prints `hi`, then `bye`.

While the string runs, its traps replace the Perl program's own handlers for those signals (`%SIG`); afterwards the program's handlers are restored.

Still rejected: `trap` alone and `trap -p` (bash also lists `TSTP`, `TTIN`, `TTOU`, dash does not); `ERR`, `DEBUG`, `RETURN` (bash only); `wait %1` (job numbers).

Measured, `trap`: 9 / 0 / 1 / 2 / 5 / 9 / 13; `wait` and `umask`: 11 / 0 / 0 / 0 / 0 / 3 / 2.

Cost: executor about 40 lines.

## nogo
`trap` and `wait` stay rejected.

## refs
Spec section 4, builtins.

@@ F9 | POSIX features to add | Parameters | Accept `$$` as the Perl program's process ID, and let the shell itself handle `kill … $$`
## now
`$$` is rejected (spec section 5): a shell sets it.

## plan
Under `sh -c`, `$$` is the process ID of that `sh`. The interpreter has no such process, so `$$` gives the process ID of the Perl program at the moment the string started to run; it stays the same in forked copies and new perl processes (A6), as `$$` stays the same in sh's subshells. Typical uses then work as before, for example a unique file name `/tmp/x.$$`.

One use would go badly wrong: `kill $$` would signal the Perl program itself, where under `sh -c` it ends only the `sh`. So when `kill` (run as the program `/bin/kill`) gets that process ID as an argument, the interpreter takes it out and handles that signal itself; any other process IDs in the same `kill` command are passed to `/bin/kill` as usual. Handling the signal: run the trap for it if F8 set one; otherwise end the shell, and the caller sees the status that `system` shows when `sh` is killed by that signal (`$? & 127` is the signal number).

Example: `trap "echo T" INT; kill -INT $$; echo after` prints `T` and `after` (as in both shells).

A remaining difference: other programs that get `$$` (for example `ps -p $$`) see the Perl program, not a shell.

Measured: 0 / 28 / 1 / 1 / 0 / 0 / 0.

Cost: executor about 15 lines.

## nogo
`$$` stays rejected.

## refs
Spec section 5, "Parameters".

@@ F10 | POSIX features to add | Arithmetic | Add the assignment operators in `$((…))`
## now
`$((x=5))` and `$((i+=1))` are rejected (V9).

## plan
Accepted: `= += -= *= /= %= <<= >>= &= ^= |=`. Example: `echo $((x=5)) $x` prints `5 5` (both shells).

`++` and `--` stay rejected: dash 0.5.12 fails on `$((i++))` ("expecting primary"), bash prints `1 2` for `i=1; echo $((i++)) $i`.

Numbers are 64-bit and wrap around as in both shells: `$((9223372036854775807 + 1))` gives `-9223372036854775808`. (The executor computes with Perl's `use integer`.)

Measured: not measured (the scan has no pattern for it).

Cost: parser about 5 lines, executor about 15.

## nogo
Assignment operators stay rejected.

## refs
Spec section 5, "Arithmetic", V9.

@@ F11 | POSIX features to add | Patterns | Expand file name patterns in the module itself
## now
Spec E3: the caller decides what to do with a word that is a pattern (expand it, refuse it, or fall back to sh).

## plan
The interpreter expands patterns as sh does: `*`, `?`, `[…]` with `!` for negation; a pattern that matches nothing stays as it is; a name starting with `.` matches only a pattern that starts with an explicit `.`. `set -f` (F6) switches expansion off. The caller can still ask the executor to refuse pattern words instead.

A pattern that could match `.` or `..`, such as `.*`, is rejected at parse time, and stops with status 248 (A4) when it comes from an expansion: dash includes `.` and `..`, bash 5.2 does not (found in the session of 2026-10-30).

Cost: executor about 35 lines, including the conversion from a pattern to a Perl regular expression, which F2 and `case` share.

## nogo
E3 stays: the caller decides.

## refs
Spec section 5, "Word splitting and patterns", E3.

@@ F12 | POSIX features to add | Builtins | Add `command`, `getopts`, `umask`, `pwd` as a builtin, and `cd -`
## now
`command`, `getopts` and `umask` are rejected as builtins; `pwd` runs as the program `/bin/pwd` (spec B2); `cd -` is rejected (spec section 4, "cd").

## plan
- `command -v NAME` prints a program's path, or the name of a builtin or function (both shells: `/usr/bin/ls`, `cd`, `f`). For the commands that both shells have as builtins but this module runs as programs (`echo`, `printf`, `test`, `[`, `kill`, `true`, `false`; spec section 4), it prints the name, as both shells do. `command NAME ARGS` runs NAME, skipping functions.
- `getopts OPTSTRING NAME [ARGS]`. Example: `getopts ab: o -b x; echo $o $OPTARG $OPTIND` prints `b x 3` (both shells).
- `umask [MASK]`: kept in the shell state; applied to files the shell opens and to programs it starts.
- `pwd` as a builtin: it prints the logical path, as both shells' builtins do (after `cd` through a symbolic link, `/bin/pwd` prints the physical path instead).
- `cd -`: changes to the previous directory and prints it (both shells).

Still rejected: `command -V`, `type`, `hash` (their text differs between the shells).

Measured, `command`, `type` and `hash` together: 99 / 13 / 0 / 1 / 2 / 7 / 3; `getopts` is counted with `shift` in F1, `umask` with `wait` in F8. By first error, 103 one-liners stop at `command`.

Cost: executor about 50 lines.

## nogo
They stay rejected, and `pwd` stays `/bin/pwd`. Say in a note if you want only some of them.

## refs
Spec section 4, B2.

@@ X1 | What stays with sh | Scope | Leave these to `/bin/sh -c`, as today
## now
All of these are rejected today.

## plan
They stay rejected when they appear in the text; when one appears only at run time, it stops with status 248 (A4):

- interactive and job-control builtins: `alias`, `unalias`, `fg`, `bg`, `jobs`, `history`, `fc`, `bind`, `help`, `complete`;
- `ulimit` (it would need a module that is not part of core Perl), `times`, `type`, `hash`;
- `shopt`: bash only, and its settings change how later text is parsed and expanded; it stays with sh even in T1;
- the listing forms `set`, `set -o`, `export -p`, `trap`;
- `$-` (dash: empty; bash: `hBc`) and `$LINENO` (in a `-c` string, dash: empty; bash: `1`);
- `++` and `--` in arithmetic, `set -o pipefail`, `local NAME` without a value;
- everything listed as "Still rejected" in F1–F12;
- everything bash-only (T1).

Measured, by first error, in all one-liners together: `alias` 80, `shopt` 53, `history` 52, `jobs` 27, `bind` 22, `help` 18.

## nogo
Name in a note the ones to add. Each is small, except `alias` (an alias changes how the following lines are parsed, so the parser would have to work line by line) and `ulimit` (non-core module).

## refs
New spec.

@@ T1 | Later | Bash | Plan a bash tier as a separate step after this one
## now
Bash-only constructs are rejected (spec section 1).

## plan
Go means: record this as the step after the POSIX tier; nothing of it is done in this round. That step would adopt alternative B or C of A3 and accept `[[ ]]`, `(( ))` and `for ((…))`, `$'…'`, arrays, `${x/a/b}`, `${x:1:2}`, `${x^^}`, `${!x}`, `<<<`, `<(…)`, `&>`, `|&`, `function f`, `declare`, `let`, `mapfile`, the options of `read`, `pipefail`, and brace expansion, only when `/bin/sh` is bash or the caller asks for bash.

Measured (plain `bash -n`): all one-liners at most 97.4% → 99.3%; bash Cookbook scripts 43% → 95%; pure-bash-bible 48% → 96%.

Cost: about 400–500 lines more.

## nogo
No bash tier: bash-only strings always go to sh.

## refs
Investigation, sections 3 and 4.

@@ S1 | Size, order of work, and a bug | Size | Set a budget of about 1,150–1,300 lines for parser and executor together
## now
`ShellSubset.pm`, the parser, has 308 lines. No executor exists; by my estimate one for the present subset would have 350–450 lines in the same style, about 700 together.

## plan
The costs of the items add up as follows (investigation, section 4, has the same list).

- Parser: 308 + F1 10 + F2 10 − F3 20 + F4 20 + F10 5 = about 335; budget 330–400.
- Executor: 350–450 for what the present subset needs, plus F1 90, F2 25, F3 10, F4 15, F5 65, F6 45, F7 12, F8 40, F9 15, F10 15, F11 35, F12 50, A6 40 = about 460; together 810–910.
- Total: about 1,150–1,250 by these estimates; up to about 1,300 with the parser at the top of its budget.

If the parser passes 450 lines in this round (S2 writes only the parser), or the whole passes 1,600 lines later (25% over), I stop and report before going on.

For comparison: mvdan/sh, a Go library that parses and runs bash, has about 12,000 lines (parser and lexer 3,892, expansion 2,750, interpreter 5,712); BusyBox's `ash.c` has 14,871 lines of C.

## nogo
Name another budget in a note.

## refs
Investigation, section 4.

@@ S2 | Size, order of work, and a bug | Order | Work in this order: U1, the spec, the parser with its tests, then the executor in a later round
## now
Only this investigation exists.

## plan
1. Fix U1 in both existing parsers, with a test each.
2. Write `claude/large-subset-spec.md` with the rules decided here, as changes against `claude/shell-subset-spec.md`: what is added, what is dropped.
3. Write the parser `claude/ShellSubsetLarge.pm`. Tests: the existing 816, with the expected result changed where the large subset accepts more, and new tests for each F item. Run `verify.pl` on them: it runs each accepted string in dash and bash with every external program replaced by a recorder, and compares the recorded command lines with what the tree predicts (it is in `corpus-run.tar.gz-base64.txt`). Then rerun the corpus measurement: for the one-liner sources, the share accepted; for the script sources, two numbers, the share of scripts accepted whole and, for the others, the share of their lines that lie in accepted stretches (cut into stretches as in `claude/shell-subset-corpus-stats.md`, section 2).
4. The executor comes in a later round, once the tree format of step 3 is settled. This page decides only what it must do.

## nogo
Write the order you want in a note.

## refs
`claude/shell-subset-tests.pl`, `claude/shell-subset-corpus-tests.pl`, `corpus-run.tar.gz-base64.txt`.

@@ U1 | Size, order of work, and a bug | Bug | Fix `ShellSubset`: reject strings that start with `-` or `+`, after any white space
## now
Perl hands a string with metacharacters to `/bin/sh` as `sh -c STRING`, with nothing between `-c` and the string. sh then reads a string starting with `-` or `+` as more options, and fails before running anything.

Perl first skips white space at the start of the string, so this also holds when blanks, tabs or newlines come before the `-` or `+`.

Example: `perl -e 'system("-x foo; true"); print "$?\n"'` prints `sh: 0: Illegal option -` and then `512` (exit status 2). The same for `" -x foo; true"` and `"\t+x foo; true"` (checked).

`ShellSubset::parse("-x foo; true")` accepts the string, as a program `-x` with the argument `foo`, then `true`. An executor would run `-x` and `true`, and give status 0. `ShellSubsetSmall.pm` does the same.

## plan
Both parsers, and the large one, reject a string whose first character other than white space is `-` or `+`, with the message "starts with - or +: sh would read it as options". A rejected string goes to `sh -c`, which fails exactly as Perl does today. One line in each parser, and tests for both cases.

The bug report for the two parsers is in `claude/large-subset-upstream-reports.md`.

## nogo
The bug stays, and so does the report.

## refs
`claude/ShellSubset.pm`, `claude/ShellSubsetSmall.pm`, their tests.

