Repository navigation
v0.0.38
A shell parser, interpreter and formatter in Go. One core — the common
denominator of real shells — with bash, zsh, ksh, dash and ash
as presets over a semantics vector rather than forks of one another.
This is a 0.x tag and means it. Early, and breakage is expected.
What changed since the last tag is the changelog below.
What is in the archive
Seven binaries: the substrate driver sh, the five dialect binaries, and
shfmt, which lays a script out in whichever dialect it was written in.
They install into libexec and never into a bin directory — the
shells are named after the shells they model, so a directory ahead of
/bin on PATH would answer for every program on the machine that
resolves a shell by name, and shfmt stays out of bin so that
homebrew-core's formula of the same name can still be installed.
brew install blairham/tap/sh
Where it stands
Each dialect graded against the real shell it models, on macOS against the
golden record make oracle writes, 2026-10-04, 4488 cases each:
| binary | against | exact | behavioral |
|---|---|---|---|
bash |
bash 5.3.20 | 99.8% | 99.8% |
zsh |
zsh 5.9.2 | 99.8% | 99.8% |
dash |
dash | 99.9% | 99.96% |
ksh |
ksh93 AJM 93u+ | 99.1% | 99.5% |
ash |
BusyBox 1.37 ash | 99.5% | 99.6% |
Exact is byte-identical stdout, stderr and status; behavioral allows a
diagnostic to be worded differently when the status and standard output
agree. Every case still short of exact is listed per dialect in
internal/oracle/testdata/conformance/ with a written reason, and CI
fails a change that adds to a list.
What is not there yet
The README's "What is not there yet" is the current list, and it is real.
The sandbox contains the shell rather than the process tree, and there is
no Windows target. Diagnostic wording is a long tail rather than the
headline gap: a bounded sample of twenty everyday error situations — a
missing command, cd onto a file, a redirect that cannot open, an unknown
option to six builtins — comes back byte-identical on standard error and
exit status for every one of the five, 100 of 100 on 2026-09-27. The probe
is internal/cmd/diagsample and takes a few seconds a column, so this line
can be re-measured rather than inherited.
Changelog
Dialects
- ash: a read that ends short keeps only the fields whose separator arrived (#7096)
- ksh, bash: an
evalputs the shell back on its own line (#7132) - ksh: a
}beginning a word in a$( )body is the reserved word (#7107) - ksh: a frozen operand's refusal ends a function call through
command(#7130) - ksh: a frozen prefix entry's refusal stands where the per-entry trace has got to (#7110)
- ksh: a prefix is traced where it lands, through
commandand around afunction-form call (#7093) - ksh: a program on standard input names its first line (#7112)
- ksh: trace an assignment through a name reference under the variable it lands in (#7116)
- zsh: a -c string that ran out locates its EXIT trap at line 1 (#7101)
- zsh: a
[[ ]]whose operand would not expand runs to its end (#7141) - zsh: a stop inside a call loses the EXIT trap the call held (#7082)
- zsh: a substitution's child writes its handed text where zsh does (#6711) (#7119)
- zsh: leave precommand modifier words out of the xtrace line (#6967) (#7124)
- zsh: posixbuiltins makes the declaration family special (#7129)
- zsh: the EXIT trap runs inside the eval text or sourced file that exited (#7138)
The language and the interpreter
- driver: a fatal signal from outside runs the EXIT trap before it ends the shell (#7070)
- interp, zsh: a refused pattern behind a declaration's assignment keeps $? (#7126)
- interp: a HUP in zsh runs zshexit, and inside a call only the call's own trap (#7150)
- interp: a builtin writing past ulimit -f dies of SIGXFSZ where it stands (#7171)
- interp: a caught fatal signal ends a ksh
functioncall, not the shell (#7158) - interp: a child inherits the dispositions of the shell that started it, not the process's (#7091)
- interp: a failure that ends the shell leaves the prefix entries ahead of it (#7099)
- interp: a held prefix entry carries the export its route gives it; ksh exports a prefix at export (#7080)
- interp: a ksh subshell waits out a fatal signal from outside before the shell dies (#7161)
- interp: a refused placement keeps what the literal placed, in the columns that stream (#7077)
- interp: a signal landing between a read's two questions at a byte still gives it up (#7185)
- interp: an expired zsh
read -korread -qleaves its name alone (#7183) - interp: an inherited ignore goes back when the last Runner holding the signal lets go (#7162)
- interp: an untrapped SEGV kills ksh93 where it lands, with no EXIT trap (#7151)
- interp: answer the shell's own copy of a group kill at the send (#7163)
- interp: ask the dialect what
read -n 0reads (#7182) - interp: give a ksh93 read up by where its stream came from, not its open mode (#7180)
- interp: ksh93 and zsh answer an interactive ALRM their own way (#7111)
- interp: ksh93's EXIT trap decides how a dying shell leaves (#7143)
- interp: let a background job go when its body waits for a job of its own (#7087)
- interp: place a declaration's array literal before the operand behind it expands (#7104)
- interp: run a trap's handler in the middle of a read that goes on, where the shell does (#7174)
- interp: run several waiting handlers in the dialect's order, not arrival order (#7167)
- interp: the EXIT trap of a self-aimed death reads what the shell held (#7147)
- interp: zsh, dash and ash wait for the foreground program before dying of an INT (#7089)
Other
- release: build instead of re-running the tests (#7073)
- release: give the tests the 30m package timeout CI's race leg has (#7072)
- testenv: answer a shell half's crash so macOS writes no report of it (#7155)
- tests: the trapped-read give-up rows wait for what they assume, not a clock (#7074)
- xtrace: a declaration's prefix and array operand are traced where they are expanded (#7076)
Full Changelog: v0.0.37...v0.0.38