Skip to content

v0.0.37

Choose a tag to compare

@github-actions github-actions released this 30 Oct 15:17
· 166 commits to main since this release
v0.0.37
4d43cbe

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 -n fails 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 read when each shell does (#7034)
  • interp: an entry behind a subscripted prefix sees the element it wrote (#7018)
  • interp: an expired read -t takes 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