test: enumerate the fixture corpus instead of growing it by example - #67
Merged
Conversation
This was referenced Aug 5, 2026
ivarvong
approved these changes
Aug 5, 2026
Cases in test/fixtures/bash_cases are joined to the bash output recorded in
test/fixtures/bash_expected by content_hash, a digest of the case inputs. Nothing
in the repo computed that digest. It was only ever read, as an opaque join key,
which left two holes:
- A case could not be added programmatically. fixture_test.exs raised a
CompileError on a missing hash and pointed at a script that never emitted
one, so the only way in was to hand-compute a 16-hex-char value by an
unknown recipe.
- A stored hash that is never re-derived cannot detect its own staleness. Edit
a script, leave the hash, and the case keeps passing while asserting against
output recorded for a different script.
62 of 968 cases had drifted that way, across cp, head_tail, mv, real_world
(29/29), special_variables and wc. Re-recording them shows no behavioral change:
the expectations were right for the current scripts, so the hazard was latent
rather than realised. It no longer is.
JustBash.Fixtures is now the one definition of the digest, recovered from the
corpus so the 906 sound hashes keep validating:
sha256({"files":<compact json>,"script":<compact json>}) |> hex |> first 16
Only the fields that feed a recording are hashed. Renaming a case, or widening
what it tolerates via opts, must not orphan a recording that is still
byte-for-byte correct — the property fixture_test.exs already claimed in a
comment but nothing enforced.
fixture_test.exs now recomputes the hash instead of trusting it, and refuses to
compile on a stale hash or a collision. Both make every assertion in a suite
untrustworthy, so they fail the build rather than one test.
Two companion tasks, and one recorder fix:
- mix bash_fixtures.verify — the same checks as a report, offline. Because the
hash comes from the inputs, "edited a script but never re-recorded" is
detectable with no Docker at all, which is what lets CI stay hermetic.
- mix bash_fixtures.rehash — adopts edited scripts. Deliberately does not touch
expectations: it leaves them unclaimed so verify says so and the re-record
diff shows what the cases should have been asserting. Rewrites through
ordered JSON so a hash change is a one-line diff, not a reordered file.
- mix bash_fixtures piped Docker's stderr into Jason.decode!. Any byte Docker
writes there — image platform mismatches, pull progress — corrupted the
payload, silently, since a truncated parse fails far from its cause. The
runner now writes to a mounted file. It also checks at record time that every
live case is covered, against the container it just ran.
Drops the locked:true mechanism. No fixture used it, and a recording that
silently contradicts real bash is the artifact that hides drift.
4033 tests, 0 failures. format, compile --warnings-as-errors, credo --strict and
dialyzer clean.
…n flags `date +%F` printing the literal "%F" at exit 0 was fixed in 274e86b by adding %F, %T, %R and %D. Enumerating the rest of the alphabet against real GNU date shows the fix closed instances, not the class: 21 more conversions were still printing themselves at exit 0, and 7 flags were still being silently discarded. The reason nobody noticed is visible in the corpus. date.json has 30 hand-written cases and covers none of %F, %D, %T, %R, -I or -v — it grew by example, one case per bug already found, so a directive nobody had thought about had no case and looked exactly like a passing one. A command's conversion and flag sets are finite. They are not a space to sample; they are a list to enumerate. mix bash_fixtures.gen writes that enumeration out as ordinary case files, so recording and comparison work as they already do. `mix bash_fixtures.gen date` produces 179 cases against real GNU date, which found: - 21 conversions emitted verbatim at exit 0: %c %g %G %k %l %q %r %U %V %W %x %X %z, and every field-flag form (%-d %_d %0d %^a %#a %:z %::z %:::z). - 7 flags silently discarded, each returning today's date at exit 0: -v+6m, -v +6m, -v-1d, -Z, --not-a-flag, and -j/-jf. This is issue #64 exactly, and it is the failure that had an agent file a goal six months in the past and report success. Unknown flags are now refused with GNU's own message. - -R emitted the default format rather than RFC 5322. - -d rejected two forms it should accept: @seconds, and an ISO timestamp with no offset (DateTime.from_iso8601/1 requires one). Each base instant only exposes what it happens to distinguish, which is its own version of the same blind spot: at day 15 and hour 13, %-d, %_d and %0d all render "15" and every padding bug hides. Every conversion is therefore recorded at two instants, one with a single-digit day and a morning hour. Two things the oracle corrected mid-fix, both worth keeping: - The existing composition property in property_test.exs caught the first attempt treating %#%! as a modified %%. Real date refuses % as a conversion for a flag run: it emits the flags literally and starts a fresh directive at the %, so %-%d is "%-15". The property was right; those sequences are now pinned in the matrix rather than resting on one generated example. - Folding flags one at a time renders %-0d as "5". Real date renders "05" — the last padding flag wins outright, so padding is decided once from the whole run instead. 13 divergences remain, each recorded with the reason it stands rather than left out of the corpus: GNU's -d relative grammar ("6 months ago", "next tuesday") is unimplemented; -j and -f are documented BSD spellings GNU rejects; and %N pads with trailing spaces under _ and -, unlike every other conversion. A recorded gap is countable and has a reason attached. An absent case looks like a passing one, which is how this class survived the last fix. known_gap rides in opts, which the digest excludes, so marking or closing a gap never invalidates a recording. 4212 tests, 0 failures, 14 skipped. All five gates clean.
Refusing unknown flags in the previous commit refused `-r` along with them, but `-r FILE` / `--reference=FILE` is a real GNU flag: it reports the file's modification time. Rejecting it traded a silent wrong answer for a wrong error, which is better but still wrong, and it left issue #64's third reproduction case failing for a new reason. The VFS records mtimes, so `-r` reads from the sandbox rather than the host clock. Errors go through FS.strerror/1, so descending through a regular file reports ENOTDIR rather than the "No such file or directory" that 33 command modules still hardcode for every error kind. Also distinguishes "flag needs an argument" from "no such flag" for -d, -f and -r. The reason to refuse a flag at all is that the message tells the caller what to do differently, which a wrong message does not. -r's success path cannot go in the date matrix: the mtime of a file created during recording belongs to the recording clock, so the two engines cannot agree on it by construction. Its error paths are enumerated there; the success path is a unit test against a seeded mtime. Closes #64.
The date matrix enumerated two alphabets — every conversion, and a handful
of flag spellings — and never crossed them. That is how `%-T` shipped
rendering "9:05:03": no case ever asked a flag and a conversion in the same
breath, and an absent case looks exactly like a passing one.
Crossing them takes the matrix from 182 to 672 cases and finds 114
divergences, all of them exit 0 with a wrong answer:
* a padding flag mangled the compound conversions — `%-T`, `%-D`, `%-R`,
`%-r`, `%-x`, `%-X`, `%_D` — by stripping characters from a rendered
string rather than restating a numeric field's width
* `%E` and `%O` printed themselves verbatim, though GNU accepts them for
51 of the conversions crossed here
* `#` was treated as a synonym for `^`, so `%#p` was "AM" and `%#Z` "UTC"
* `%z` was a literal rather than a signed number, so `%-z` kept the
padding it was asked to drop and `%8:z` ignored its width entirely
* a width was a floor rather than a replacement, so `%1d` was "05"
The renderer now models a conversion as a field — a number with a value,
digit count and pad character; a signed zone offset; text; or a sub-format
— and lets the flags restate that field rather than edit its output. The
rules it follows are the ones the recording implies, not the ones the
documentation suggests: a width replaces the conversion's own digit count
instead of raising it, the last padding flag wins outright, a modifier
drops the padding flag, and a compound forwards its flag to year fields
only, which is why `%-D` is "01/05/5" while `%-x` is "01/05/05". The E and
O acceptance sets are transcribed from real date rather than derived: they
overlap without containing each other, and `%Eq` renders where `%Oq` does
not.
Three base instants where there were two, plus a fourth for the year
fields: June cannot distinguish `%-j` from `%j`, and no modern year can
show `%_Y` padding at all.
Argument parsing gets the other half. Refusing unknown flags had also
refused `--` and the separate-argument spellings `--date VALUE` and
`--reference VALUE`, which GNU accepts. `-d` with `-r` silently answered
one of the two questions asked. And `-d @N` checked the shape of the
number but not whether it was a representable instant, so a large enough
one raised out of the command instead of being refused.
…ails Three ways the corpus could report success it had not earned. A `known_gap` was a skip. A skip can only ever report the divergence someone wrote down; it cannot notice the divergence is gone, so a gap closed by an unrelated fix stays marked forever and the count stops counting anything. The assertion is inverted instead — a gap case runs and fails when its output *matches* the recording. It found a stale marker on its first run: `-jf` is one clustered token that both engines refuse on the same byte, never the BSD `-j -f` pair it was named for. That pair is now enumerated separately, and it is the real gap. `verify_recorded!` did not raise despite the name, and the recording it had just rejected was written anyway, over a good one, at exit 0. Nothing downstream could tell that recording from a clean one: the file it would have replaced is still there and still passing. It now returns an error, the unusable recording is not written, and the task raises at the end naming every suite that failed. A case with no `"script"` raised a FunctionClauseError from inside `validate/2`, which reads as a bug in the checker rather than a malformed case file. It says which now.
davydog187
force-pushed
the
test/fixture-corpus-integrity
branch
from
August 5, 2026 22:19
0e4f9d5 to
68809e5
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Groundwork for a differential testing story that finds bugs before a production agent does, plus the first enumerated matrix and the bugs it found.
Why
The recent fix wave (#56, #57, #60, #63) was found reactively: an agent driving the sandbox returned a wrong answer, a human noticed, an issue got filed. There is no proactive enumeration. The obvious remedy is "run real bash in a container and compare" — but that already exists and works.
mix bash_fixturesrecords real bash in Docker andfixture_test.exsbyte-compares stdout/stderr/exit across 33 suites.The gap is what the corpus contains. One measurement settles it:
The existing, unmodified oracle would have caught that bug with ~25 lines of JSON. It shipped because nobody wrote down strftime's alphabet. The corpus grew by example — one case per bug already found — so a directive nobody had thought about had no case, and an absent case is indistinguishable from a passing one.
A command's conversion and flag sets are finite. They are not a space to sample with a fuzzer; they are a list to enumerate.
What's here
1. The corpus verifies its own content-addressing (
2bffbbd)Cases and recordings are joined by
content_hash, and nothing in the repo computed it — it was only ever read, as an opaque join key. Two consequences: a case couldn't be added programmatically (the only way in was to hand-compute a 16-hex value by an unknown recipe), and a stored hash that is never re-derived can't detect its own staleness.62 of 968 hashes had drifted. Re-recording them produced no behavioral change — the expectations were right for the current scripts, so the hazard was latent rather than realised. It no longer is:
JustBash.Fixturesowns the digest,fixture_test.exsrecomputes rather than trusts it, and a stale hash or collision now fails compilation instead of silently mispairing.mix bash_fixtures.verify— the same checks offline. Because the hash comes from the inputs, "edited a script but never re-recorded" is detectable with no Docker at all, which is what lets CI stay hermetic.mix bash_fixtures.rehash— adopts edited scripts. Deliberately leaves expectations unclaimed soverifysays so and the re-record diff shows what the cases should have been asserting.Jason.decode!. Any byte Docker writes there — image platform mismatches, pull progress — corrupted the payload silently, since a truncated parse fails far from its cause. It now writes to a mounted file.fixture_test.exs:27already claimed in a comment but nothing enforced.locked:truemechanism: a recording that silently contradicts real bash is the artifact that hides drift.2.
mix bash_fixtures.gen+ the date matrix (e3336f6,e50c105)182 enumerated cases against real GNU date. In a command that already had 30 hand-written cases, they found 40 divergences:
%c %g %G %k %l %q %r %U %V %W %x %X %z, every field-flag form (%-d %_d %0d %^a %:z %::z %:::z)-v+6m,-v-1d,-Z,--not-a-flag,-j,-jf-Remitted the default format, not RFC 5322-dinput rejected@SECONDS, offset-less ISO-r FILEThe first class is the same bug #63 fixed for
%F. That fix closed instances, not the class — 21 more conversions were still printing themselves at exit 0. The second is #64 exactly, and it is the failure that had an agent file a goal six months in the past and report success.All fixed.
date -rnow reports a file's mtime from the VFS, with errors throughFS.strerror/1so descending through a regular file reports ENOTDIR rather than the hardcoded "No such file or directory" that 33 command modules still use for every error kind.Two things the oracle corrected mid-fix
Both worth keeping, because both are cases where reading the docs would have left the bug in:
property_test.exscaught the first attempt treating%#%!as a modified%%. Real date refuses%as a conversion for a flag run: it emits the flags literally and starts a fresh directive at the%, so%-%dis%-15. The property was right.%-0das5. Real date renders05— the last padding flag wins outright, so padding is now decided once from the whole run.Enumeration lessons, encoded
One base instant hides half the bugs. At day 15 and hour 13,
%-d,%_dand%0dall render "15" and every padding bug hides. Every conversion is recorded at two instants, one with a single-digit day and a morning hour — which is what surfaced%_3d.Gaps are recorded, never omitted. 13 divergences remain, each with the reason it stands: GNU's
-drelative grammar ("6 months ago","next tuesday") is unimplemented,-j/-fare documented BSD spellings GNU rejects, and%Npads with trailing spaces under_and-unlike every other conversion. Aknown_gapopt marks them; it rides inopts, which the digest excludes, so marking or closing a gap never invalidates a recording. A recorded gap is countable and has a reason attached; an absent case looks like a passing one, which is how this class survived #63.Verification
All five gates from CLAUDE.md:
mix format --check-formatted,mix compile --warnings-as-errors,mix credo --strict(only the intentional test-fixture finding),mix dialyzer,mix test— 4219 tests, 0 failures, 14 skipped.mix bash_fixtures.verify: 34 suites sound. Corpus is now 1150 recorded differential cases.Closes #64.
Follow-up work is tracked in #70 (the remaining matrices, oracle hardening, oracle-free invariants, the Oils subset and CI topology), with the two prerequisites split out as #68 (unknown flags absorbed as operands) and #69 (builtin raises escape
exec/2;Limithas no wall-clock bound).