Repository navigation
Releases
v0.0.41
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.
Performance
Full Changelog: v0.0.40...v0.0.41
v0.0.40
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.
Fixed
- driver: a run of MainArgs takes its fatal-signal watch down as it returns (#7372) (28e2638)
- driver: take a stop aimed at the shell before kill returns (#7300) (d94dd5f)
- interp/zsh: a pipeline element keeps the parent's traps working in zsh (#7346) (8c89231)
- interp: a bare array literal that fails to expand still makes the name an array (#7315) (83808f0), closes #7078
- interp: a bash declaration keeps the literals it placed when its utility refuses (#7373) (89bde19)
- interp: a bash prefix is refused word by word in written order (#7340) (e6b2478), closes #7095
- interp: a copy's untrapped fatal signal ends a body's wait at once (#7386) (f2b5175), closes #7383
- interp: a declaration expands its array-literal operand where it is written (#7312) (d9059f9), closes #7075
- interp: a declaration under set -a takes the export attribute (#7303) (424f4f8)
- interp: a failed bare literal lists and keeps the name as the shell left it (#7325) (bb6a33f), closes #7316 #7317
- interp: a group kill at a forked body's own pid is no such process without the monitor (#7305) (75dce60)
- interp: a ksh append to an array emptied by unset lands where ksh93 puts it (#7394) (13de311)
- interp: a ksh call to a traced function works its prefix through again inside the body (#7336) (b4d38f6)
- interp: a ksh declaration operand through a reference is traced under the variable it lands in (#7351) (c60c921), closes #7102
- interp: a ksh declaration over a function's own reference stores through it (#7356) (6b35e6c), closes #7352
- interp: a ksh element write through an element reference makes the element an array (#7363) (d3aa913)
- interp: a ksh frozen prefix entry is refused before the declaration's operands expand (#7338) (81bbb29)
- interp: a ksh keyed literal assigned to an element nests a table (#7396) (98ac724)
- interp: a ksh name reference aimed at a non-name is an invalid variable name (#7330) (2b02c9f)
- interp: a ksh persisting prefix is stored ahead of a redirection that fails (#7399) (627ca04)
- interp: a ksh persisting prefix is stored ahead of the redirections (627ca04), closes #6986
- interp: a ksh read through an element reference reads the nested element (#7369) (16295fb)
- interp: a ksh reference operation resolves through the reference already there (#7359) (dc9a828)
- interp: a ksh subscripted prefix in a command's own scope is a fresh indexed array (#7397) (b7dbba5), closes #6982
- interp: a refused bash element write through an element reference gives up the line (#7371) (1c9f00a), closes #7367
- interp: a trapped signal cuts a body's wait short, and a job's early signal lands after the shell goes on (#7384) (52532c4), closes #7086
- interp: a zsh appending literal with a subscripted element stores from the first position (#7320) (7a06f95)
- interp: a zsh subscripted literal places an empty element and refuses a negative subscript (#7385) (4f43692)
- interp: an element's death by SIGPIPE leaves the outer PIPE handler alone (#7314) (9b52f23)
- interp: an interactive ksh keeps a persisting prefix's entries ahead of a failed one (#7349) (251fa88), closes #7098
- interp: answer a ( ) or $( ) copy's signal befor...
v0.0.39
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
- ksh:
${a[k]=x}and${a[k]?m}make a missing table key before the test (#7213) - ksh: a declaration names itself for an operand it reads itself (#7207)
The language and the interpreter
- interp, ksh: open a bare
<>on standard output in ksh93 (#7219) - interp: a refused element store leaves the trace bash and ksh93 leave (#7190)
- interp: ask the dialect about a
read -nof a pipe exec opened, and one that finds the end (#7217) - interp: evaluate an absent name's subscript once, and stop arithmetic whose text did not expand (#7191)
Other
- spec: record the refused store into a frozen indexed array as the allocated slot's third route (#7214)
- tests: ask the child whether a closed stream is open, not cat and echo (#7200)
- tests: send ksh/second signal, both ways its INT with a block still to come (#7215)
Full Changelog: v0.0.38...v0.0.39
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
v0.0.37
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: -i -c goes on to a prompt on standard input after a fatal error (#6924)
- ash: a short
read -nfails at 1 and keeps what arrived (#7037) - ash: an EXIT trap after a program that ends on a
.is located in the file (#6908) - bash: $BASH_COMMAND keeps a braced parameter's braces (#6921)
- bash: read -a and mapfile through a reference to a frozen array name the reference (#6974)
- dash: -i FILE is read at a prompt, PS1 and PS2 drawn for its lines (#6917)
- ksh, zsh: a scope's EXIT trap fires where the scope ended (#6904)
- ksh, zsh: read -A into an indexed array's element: ksh93 refills from the first, zsh splices (#6994)
- ksh: -i reads a standard input it can rewind as a script, not at a prompt (#6913)
- ksh: a
}inside a$((ends the construct there (#6957) - ksh: a declaration's bad name ends its declaring, not its assigning (#6959)
- ksh: a declaration's operands are stored and traced one at a time (#6964)
- ksh: a fork inside a command substitution's body holds one number more (#6919)
- ksh: a function call's prefix over SECONDS, LINENO or RANDOM keeps the shell's parameter (#6970)
- ksh: a prefix in front of typeset in a function-form body is its local (#7014)
- ksh: a prefix's subscript is read in turn, and its trace line follows it (#6981)
- ksh: a script's letters reach SECONDS, RANDOM and LINENO (#6978)
- ksh: a valueless element past the first over a frozen name holding nothing is refused (#6912)
- ksh: an EXIT trap after a fatal error reads the status from before the failing command (#6965)
- ksh: an element literal is stored through a frozen container (#6975)
- ksh: build a keyed literal over a table a pair at a time (#6916)
- ksh: getopts' empty-key warning is behind the shell's name alone (#6922)
- ksh: the rest of the dotted-name routes: for, select, read, (( )), let, .sh and a refused prefix's trace (#6898)
- ksh: unforked parentheses hold a number, and exec in a body saves for the body (#6915)
- zsh: &>file holds the file while its saves are taken, and joins its streams (#6923)
- zsh: a coprocess holds the first and fourth of four numbers, and its body holds too (#6928)
- zsh: a flag group on an element literal's target names the element (#6948)
- zsh: a redirection list that joins one descriptor both ways is refused (#6903)
- zsh: a written declaration operand is split after its subscript (#6961)
- zsh: a {name} whose target is a process substitution takes its number (#6926)
- zsh: a {name}&> reaches standard error for the save and the join (#6756) (#6972)
- zsh: locate a subshell's EXIT trap where the subshell opened, or at its exit (#6951)
The language and the interpreter
- driver: an untrapped signal from outside ends the shell again, reset or not (#7060)
- driver: the low-descriptor ceiling is 56 on macOS too (#6995)
- interp: Diagnostics methods take the table by pointer, so no frame holds a copy (#6958)
- interp: a command's records are set aside when it starts, not only put back (#7000)
- interp: a copy that has not moved opens no hold, so a clone leaks none (#6979)
- interp: a copy that moved holds its directory from the first ask (#6560) (#6977)
- interp: a declaration's operand sees the prefix it was expanded behind (#7008)
- interp: a file a redirection opened is out of a substitution's way (#6929)
- interp: a file test on a borrowed substitution number asks about the pipe (#6907)
- interp: a flagged assignment's value is a value, not a command-line word (#6971)
- interp: a read given up or timed out leaves no read parked on its stream (#7029)
- interp: a trapped signal ends zsh's timed read still waiting for its first byte (#7041)
- interp: a trapped signal gives up a
readwhen each shell does (#7034) - interp: an entry behind a subscripted prefix sees the element it wrote (#7018)
- interp: an expired
read -ttakes no byte it does not keep (#7026) - interp: bash shows a written arithmetic expression without its double quotes (#6909)
- interp: keep the trees of arithmetic text read at run time (#6996)
- interp: nothing a script aims at the shell alone reaches the kernel (#7045)
- interp: one Runner's ignore or reset no longer deafens another's handler (#7053)
- interp: qualifier code in an assignment list: the trace line, the status, the files (#6976)
- interp: run the pathname stage once every word of a list is expanded (#6956)
- interp: stop paying for bookkeeping every command undoes (#6998)
- interp: time one append at two lengths, not a whole build at two sizes (#7023)
- interp: zsh's local b[1]=(p q) cannot create the local (#6914)
- interp: zsh's prefix a+=z over an array adds an element (#6901)
- interp: zsh's prefix h+=z over a table replaces it (#6925)
- syntax: an assignment's subscript text keeps the blanks inside its brackets (#6920)
- syntax: bash holds a process substitution in a subscript as its laid-out text (#6927)
Other
- README: replace the fanned-chevron logo with a terminal tile (#6889)
- Remove committed probe scratch, and stop the fc -p test writing into the tree (#6933)
- Remove the container images (#6932)
- Revert the gated /bin/sh change (#6930)
- Test harness: binary tests build the shipped shell; dialect cases as data (#6947)
- acp-connect: -acp-prompt FILE sends a whole file as one turn (#6943)
- acp-connect: end an agent that outlives its input, and its whole group (#6940)
- acp: count a tool call once however many times it is announced (#6941)
- cmd/zsh tests: wait for the wrapper's whole line, and for the new prompt (#7019)
- examples: hold a coding agent to a policy over -acp-connect (#6946)
- gated: put the cmd/sh recipe in the main/run shape consumers need (#6911)
- ksh tests: SECONDS listed under -E 2 may have spent more than a millisecond (#6992)
- lint: gci joins the formatters, and internal/ imports join the module group (#6963)
- lint: gofmt joins the formatters the Lint job runs (#6950)
- lint: golines joins the formatters, and the tree wraps at 120 columns (#6973)
- mcp: send ttlMs and cacheScope on modern list results (#6939)
- policy: deny exec-argv, so a rule can refuse a subcommand (#6942)
- release: quote the README's conformance table, not the one it retired (#7071)
- spec: record ksh93's allocated-unset indexed slot whole, and do not model it (#6968)
- suite: autoload probe writes to a scratch dir, not the checkout (#6931)
- testenv: close the descriptors a suite inherits (macOS main red) (#6945)
- zsh tests: SECONDS under -F 5 is read until it moves (#6980)
- zsh xtrace: write an element literal's words once they are built (#6906)
Full Changelog: v0.0.36...v0.0.37
v0.0.36
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, over 4488 cases —
make conformance-dialects:
| binary | against | exact | behavioral |
|---|---|---|---|
bash |
bash 5.3.20 | 98% | 100% |
zsh |
zsh 5.9.2 | 98% | 99% |
dash |
dash | 98% | 100% |
ksh |
ksh93 AJM 93u+ | 96% | 99% |
ash |
BusyBox 1.37 ash | 93% | 99% |
Exact is byte-identical stdout, stderr and status; behavioral allows a
diagnostic to be worded differently when the status and output agree. The
distance between the two columns is almost entirely message wording.
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 backquoted body starts on the line above its opener (#6894)
- bash, ksh: trace a redirection target's process substitution body (#6885)
- bash: $BASH_COMMAND lays out a command substitution's body (#6877)
- bash: a backgrounded pipeline fires the DEBUG trap in the shell that starts it (#6850)
- bash: a move's '-' must be written, and a {name} dup refusal names the name (#6875)
- dash, ksh, ash: a ( … ) at the tail of a -c string is the shell, and its last program replaces it (#6856)
- ksh, zsh: a base declared on RANDOM, LINENO or SECONDS is listed, and read in zsh (#6855)
- ksh: a chained element literal nests at the end of its chain; trace a negative subscript as its position (#6879)
- ksh: a persisting prefix makes a typed name a plain scalar; a keyword call's cell has no letters (#6860)
- ksh: a process substitution is a word of its own (#6867)
- ksh: command before a builtin takes the export off, and a case letter keeps it (#6862)
- ksh: name the element in three frozen-name refusals, and drop read -A's warning (#6834)
- ksh: quotes in an arithmetic subscript: scanned, removed, and traced as ksh93 does (#6891)
- ksh: read -A into a table's key nests; a counted read fills its first name whole (#6870)
- ksh: three parse refusals named the way ksh93 names them (#6872)
- zsh, ksh: an external command's prefix is expanded in its child (#6861)
- zsh, ksh: getopts leaves an unset OPTIND unset and keeps its own place (#6842)
- zsh: a declaration number with no name is a listing, and local's letters filter (#6831)
- zsh: a declaration's store to 0 sets $0, and a literal's sentence follows the route (#6840)
- zsh: a function-local EXIT trap skips a give-up and reads 0 after an exit (#6874)
- zsh: a parse failure quotes the script visibly in its locale (#6897)
- zsh: a refused
setoption is an error condition to an always block (#6863) - zsh: control bytes in arithmetic, a word after a then-body group, unknown prompt escapes (#6846)
- zsh: ignorebraces takes the {name} redirection away (#6893)
- zsh: refuse a process substitution against a plain redirection, or with more after it (#6858)
- zsh: the M and T glob qualifiers, and markdirs marks what e is shown (#6888)
- zsh: unset drops a script binding's hide word, and leaves a detached tie's other half (#6841)
- zsh: zmodload refuses a module name by its spelling before loading it (#6844)
The interactive shell
- repl: a
returntyped at a prompt ends the line or the session, never neither (#6832)
The language and the interpreter
- interp, driver: an interactive shell ignores TERM, from itself or from outside (#6845)
- interp: a declaration operand's = is looked for after its subscript (#6895)
- interp: a frozen array's element is not written by (( )), let, getopts or ${a[k]:=} (#6848)
- interp: a frozen element or list written through a reference names the reference (#6886)
- interp: a positional index too wide for any list is past the end (#6880)
- interp: a refused subscript quotes its command substitutions laid out (#6865)
- interp: a[@]=(…) and a[*]=(…) answer the whole-array fields, not the element roads (#6827)
- interp: an out-of-range or empty subscript costs the command list, not a -c string (#6853)
- interp: ksh saves the streams a redirection list replaces, except for its last command (#6837)
- interp: the nonblocking-open test opens a substitution through PathToOpen (#6884)
- interp: three per-command costs off the counting loop (#6496) (#6839)
Other
- arith: word a group's leftover byte the way the panel splits it (#6830)
- arithmetic: a value that will not evaluate is quoted with its own blanks (#6851)
- build: compile every shell with a profile of what a shell spends its time on (#6496) (#6864)
- case: an arm matching nothing is 0, whatever a pattern's substitution left (#6843)
- deps: take golang.org/x/text v0.42.0 for GO-2026-6629 (#6881)
- gated: a /bin/sh for distroless images that cannot exec, promoted from localiam (#6892)
Full Changelog: v0.0.35...v0.0.36
v0.0.35
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, over 4488 cases —
make conformance-dialects:
| binary | against | exact | behavioral |
|---|---|---|---|
bash |
bash 5.3.20 | 98% | 100% |
zsh |
zsh 5.9.2 | 98% | 99% |
dash |
dash | 98% | 100% |
ksh |
ksh93 AJM 93u+ | 96% | 99% |
ash |
BusyBox 1.37 ash | 93% | 99% |
Exact is byte-identical stdout, stderr and status; behavioral allows a
diagnostic to be worded differently when the status and output agree. The
distance between the two columns is almost entirely message wording.
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 function keeps the line count of the text it was written in (#6796)
- ash: locate an interactive -c string as the prompt (#6713)
- ash: locate and end an interactive script as BusyBox does (#6804)
- ash: name no line for eval's text in an interactive shell (#6697)
- bash, ksh, zsh: store an array-literal prefix as each shell does (#6635)
- bash, ksh: a {name} store is seen through the command's own prefix (#6699)
- bash: a move's source, a duplication's name, and a bare {name} list (#6748)
- bash: a typed value that will not evaluate gives up the line, not the script (#6708)
- bash: refuse each frozen prefix entry where the walk reaches it (#6601)
- dash, ash: set -- and shift restart the getopts scan (#6653) (#6823)
- dash: refuse a non-number stored into OPTIND, on the freeze's route (#6574)
- dialect: measure array-operation growth by allocation, not wall clock (#6457)
- ksh, zsh: a {word} before a redirection that is not a name is refused (#6592)
- ksh, zsh: hold a placed array-literal prefix for the entries behind it (#6782)
- ksh: OPTIND's listing and attribute corners, and PPID, TMOUT, MAILCHECK as integers (#6785)
- ksh: a store under a missing parent is refused, and
.shtakes no store (#6795) - ksh: a valueless subscript past the first element declares the array (#6535)
- ksh: end an interactive -c string and script with a newline, as the prompt does (#6714)
- ksh: export a prefix scoped to a function-form call, and keep a prefixed OPTIND as its cursor (#6596)
- ksh: getopts and read over a typed or frozen name, and a declared OPTIND (#6634)
- ksh: getopts's end-of-options refusal ends the script through a call, eval or . (#6688)
- ksh: hand a caller's exported local to a function-form callee (#6745)
- ksh: locate a failed redirection with no command word as the shell's own (#6765)
- ksh: locate a run-time failure past an input's first line at a prompt (#6600)
- ksh: name a here-document left open inside a substitution in a construct (#6742)
- ksh: name an unterminated here-document over the construct it is in (#6707)
- ksh: name line 1 of the shell's own EXIT trap body in its location (#6815)
- ksh: read an interactive -c string and script file as a prompt reads (#6657)
- ksh: readonly in a function-keyword function freezes only the call (#6487)
- ksh: settle a {name} that cannot take the number before its redirection (#6646)
- ksh: store an empty key in a table literal, refuse
[]=while reading (#6651) - ksh: trace a table literal as its elements are stored, refuse a [*]= key (#6780)
- ksh: typeset in a POSIX-style function declares into its keyword caller (#6509)
- ksh: warn about a table's empty key under set -x, quote a literal's traced keys (#6687)
- ksh: write a traced word, %q and whence's name with the listing's rules (#6625)
- zsh, bash: a {name} refused in an array literal is named as each shell names it (#6802)
- zsh, interp: cheaper per-call option saves, fewer autoload parses, an unsorted PATH walk (#6465)
- zsh, ksh: a prefix to a program is folded through the name's letters (#6753)
- zsh: a builtin's whole store into a position splices, and a retyping store drops -hide (#6732)
- zsh: a failed assignment's status at a prompt (#6580) (#6645)
- zsh: a frozen {name} refusing a read open writes a second line (#6585)
- zsh: a private-scope refusal through a builtin is located as the function, and leaves the store's number (#6589)
- zsh: a refused print -v, printf -v or zparseopts store leaves 0 from -c, and zparseopts -A asks the freeze (#6555)
- zsh: a traced assignment that fails ends its line, and a substitution writes its parent's (#6623) (#6712)
- zsh: a {name} before &>, and >>( and <<( as a substitution (#6791)
- zsh: an -a or -A letter in front of a literal retypes a frozen scalar, as the bare literal does (#6567)
- zsh: an always half reads the status a failure kept (#6621, #6644) (#6669)
- zsh: an error a -c string gave up over skips the EXIT trap (#6666) (#6825)
- zsh: lay the default $fpath down on first use instead of at every start (#6517)
- zsh: leave 0 where a refused getopts store ends the shell (#6700)
- zsh: make Apply's tables once at the size they end at (#6593)
- zsh: read a {name} across blanks to its redirection operator (#6664)
- zsh: refuse kind letters over $0, warn on a truncated position, and keep read -A's EOF status (#6806)
- zsh: refuse zparseopts -A over a slot whose kind is fixed (#6650)
- zsh: standard input is located the way a prompt is (#6622) (#6755)
- zsh: word a numeric letter over a set name's array literal as zsh does (#6670)
The interactive shell
- zle: $PENDING counts the typed bytes behind the key, as zsh's does (#6479)
- zle: draw PREDISPLAY before the line and honor P-flagged region_highlight (#6456)
The language and the interpreter
- driver: stop copying the 17 KB Shell onto every frame of a start (#6586)
- interp, syntax: trace a chained element write, and write a word back with its substitutions laid out (#6731)
- interp:
oe/o+sort specifiers, a top-levelreturnin qualifier code, andnin that code (#6719) - interp: a failed assignment keeps the old value, traced or not (#6544) (#6578)
- interp: a failed element write keeps the element (#6579) (#6603)
- interp: a fixed slot refuses an array prefix, and a refused set -A/read -A store leaves 0 from -c (#6516)
- interp: a frozen name is refused where each shell refuses it, and an element literal reads its two sides in the element order (#6759)
- interp: a frozen whole-array subscript is refused for its subscript where that refusal comes first (#6820)
- interp: a kept reach set moves past the last call's subject, not this one's (#6819) (#6824)
- interp: a kind letter on one half of a script's tie unties the pair (#6471)
- interp: a listing that names an engine store has not found it (#6448)
- interp: a pattern refused in a program's words fails only the program (#6577)
- interp: a prefix walk stops at a refused entry before an external, and an array prefix expands its elements (#6548)
- interp: a process substitution's body allocates from 10 under zsh's base (#6557)
- interp: a script tie holds its kind, unties beneath a local, and a local tie starts fresh (#6484)
- interp: a substitution's number skips nothing a fork would not hold (#6477)
- interp: a writing substitution's body holds its own output (#6542)
- interp: a zsh multios join holds numbers while its list applies (#6529) (#6757)
- interp: a {name} redirection on a program is that program's own (#6519)
- interp: a {name} redirection on a subshell is the subshell's in bash (#6549)
- interp: a {name} store retypes the name in zsh and ksh93 (#6561)
- interp: an array literal stops at the element that fails, and ksh keeps what it placed (#6703)
- interp: an element write expands its value and subscript in the dialect's order (#6673)
- interp: ask a glob qualifier's file one system call, and a list with no test none (#6455)
- interp: ask a relative file test of the held directory, in one call (#6533)
- interp: ask less per word and per command on a builtin's road (#6496) (#6568)
- interp: bash saves a copy of a stream for every redirection of it (#6602)
- interp: close every substitution end before waiting for any body (#6527)
- interp: every pattern refusal in a program's words fails only the program (#6594)
- interp: expand =cmd and an assignment's tilde on a copy, never in the tree (#6451)
- interp: hold the
&job running untilfgcontinues it (#6672) - interp: keep a pipeline's own pipe ends out of the numbers a substitution takes (#6453)
- interp: ksh holds a number in a command substitution's body (#6558) (#6641)
- interp: ksh93 holds a number for a pipeline's last element (#6590)
- interp: leave zsh's environment copy of an exported OPTARG or OPTIND where getop...
v0.0.34
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, over 4488 cases —
make conformance-dialects:
| binary | against | exact | behavioral |
|---|---|---|---|
bash |
bash 5.3.20 | 98% | 100% |
zsh |
zsh 5.9.2 | 98% | 99% |
dash |
dash | 98% | 100% |
ksh |
ksh93 AJM 93u+ | 96% | 99% |
ash |
BusyBox 1.37 ash | 93% | 99% |
Exact is byte-identical stdout, stderr and status; behavioral allows a
diagnostic to be worded differently when the status and output agree. The
distance between the two columns is almost entirely message wording.
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
- zsh, interp: parse an autoload stub once per name length, not once per name (#6437)
The language and the interpreter
- interp: answer an escaped literal pattern without the matcher (#6436)
- interp: keep a name's boolean attributes in its cell, and remember the last cell found (#6438)
- interp: keep export, unset and declared-empty in the cell; ask a local line's letters once (#6373, step 2) (#6442)
- interp: keep the deferred-call machinery off every command's door (#6439)
- interp: keep the plain store compiling over the attributes moved into the cell (#6446)
- interp: keep the restores every simple command makes where the compiler inlines them (#6447)
- interp: print a function's body once per definition for $functions (#6440)
- interp: print an array literal's element text only when a refusal names it (#6443)
- interp: record the shell's registered names in a filter a store asks first (#6445)
- interp: store a bare assignment of a plain value without the full road (#6444)
Full Changelog: v0.0.33...v0.0.34
v0.0.33
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, over 4488 cases —
make conformance-dialects:
| binary | against | exact | behavioral |
|---|---|---|---|
bash |
bash 5.3.20 | 98% | 100% |
zsh |
zsh 5.9.2 | 98% | 99% |
dash |
dash | 98% | 100% |
ksh |
ksh93 AJM 93u+ | 96% | 99% |
ash |
BusyBox 1.37 ash | 93% | 99% |
Exact is byte-identical stdout, stderr and status; behavioral allows a
diagnostic to be worded differently when the status and output agree. The
distance between the two columns is almost entirely message wording.
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
- zsh, interp: read autoloaded function files through the parse cache too (#6434)
The language and the interpreter
- driver, interp: keep the trees of sourced and startup files in a parse cache of our own (#6432)
- interp: read an arithmetic text's spans once per grammar, and keep dialect moves (#6431)
- interp: read anchors and complete classes in the substitution reach analysis (#6428)
- interp: read arithmetic again only when the order in force is not the one it was read in (#6429)
- interp: settle a default's test on an element from the subscript already read (#6430)
- interp: start a body's process-group placeholder ahead of the body that asks (#6421)
- interp: the scope lints say nothing about the engine's own state (#6426)
Full Changelog: v0.0.32...v0.0.33
v0.0.32
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, over 4488 cases —
make conformance-dialects:
| binary | against | exact | behavioral |
|---|---|---|---|
bash |
bash 5.3.20 | 98% | 100% |
zsh |
zsh 5.9.2 | 98% | 99% |
dash |
dash | 98% | 100% |
ksh |
ksh93 AJM 93u+ | 96% | 99% |
ash |
BusyBox 1.37 ash | 93% | 99% |
Exact is byte-identical stdout, stderr and status; behavioral allows a
diagnostic to be worded differently when the status and output agree. The
distance between the two columns is almost entirely message wording.
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
The interactive shell
- zle: a POSTDISPLAY is not an edit to the line's region_highlight offsets (#6413)
The language and the interpreter
- interp, zsh: answer ${+parameters} without building the table (#6419)
- interp: a timed read of a regular file reads it in blocks (#6418)
- interp: keep a process substitution's shell end inside the first fd table (#6408)
- interp: read a quoted value's pattern in linear time under extendedglob (#6416)
- interp: render a condition's operands for the trace only when tracing (#6417)
- syntax: ask whether a command's words are a name list once per word (#6415)
Full Changelog: v0.0.31...v0.0.32