Conventions¶
House rules for writing a page here. Readers browsing lessons do not need this file; it is for whoever is about to add one.
The shape of a lesson¶
02_Characters/
a_character_is_a_number/
README.md the lesson
examples/
a_character_is_a_number_py.py the Python program
a_character_is_a_number_py.out its recorded output
a_character_is_a_number_rs.rs the Rust program
a_character_is_a_number_rs.out
a_character_is_a_number_sh.sh the shell script
a_character_is_a_number_sh.out
a_character_is_a_number_c.c (optional) the C view, where one helps
a_character_is_a_number_go.go (optional) the Go view, where Go itself is the subject
One idea per folder. The folder name is the idea, in lower_snake_case, and it becomes a permanent URL — so name it for what it teaches, not for where it currently sits in the reading order.
A lesson does not need all three languages. It needs the ones that show something the others cannot: Python for the shortest statement, the shell for the bytes on a real pipe, Rust for the type holding the line. A lesson with one example is fine; a lesson with three that say the same thing is padding.
The page¶
Open with the title, then two lines that let a reader decide in five seconds whether this is their page:
# Hex is a shorthand
**Level:** 101 · for anyone starting from zero
**One line:** Hexadecimal is not a different kind of number. It is binary written four bits at a time, so one byte is always exactly two hex digits.
**Level:** is 101 / 201 / 301 / reference, then ·, then who it is for. The one-line summary states the claim, not the topic — "hex is bits four at a time" is a one-liner; "an introduction to hexadecimal" is a table-of-contents entry.
Then, in this order: the mechanism in prose, the generated blocks per language (## In Python, ## In the terminal, ## In Rust), the bridge, ## Try it, ## Practice if the page has a kata, ## See also.
Do not hard-wrap paragraphs. Write each paragraph as one long line and let the editor soft-wrap; Markdown collapses single newlines anyway.
Output is generated, never typed¶
Mark the spot and let the tool fill it:
tools/run_examples.py runs the program and pastes what it actually printed, with a provenance line above the fence. Inside the markers is generated; outside is yours. The stem is bare — no path, no extension — so stems must be unique repo-wide across languages, which is what the _py / _rs / _sh suffix is for. The tool refuses a duplicate.
There is a second kind, <!-- source:stem -->, which pastes the program itself. Use it when the code is the lesson — a ten-line xxd in Python, a shell script whose commands are the content — and a hand-copied fence could silently drift from the file CI runs.
python3 tools/run_examples.py # verify + refill
python3 tools/run_examples.py --update --only X # record X's output as its answer key
python3 tools/run_examples.py --check # write nothing, fail on drift (CI)
Always pass --only with --update. A bare --update re-records every key in the repo, including one somebody else is midway through editing. And read what it recorded before committing: --update accepts whatever the program printed, so it will happily enshrine a bug. The recorded key proves the page shows what the program printed; it cannot know the program is right.
A recorded key cannot hold a carriage return. The key is read back through Python's universal newlines, so a \r\n in the .out file arrives as \n and is compared against a program's stdout, which was not translated — the two can never match, and the gate reports drift with a diff that looks empty because the difference is invisible. The example most likely to hit it is the one most likely to be written: anything about CRLF. Print the CR through cat -vet (^M$) or xxd instead, which is what the page wants anyway, since the whole reason that byte needs showing is that nothing draws it.
The programs¶
Python: stdlib only, and 3.14. CI runs every example on Python 3.14 on both runners — actions/setup-python pins the minor version and lets patch releases float — so 3.14 is the version an answer key promises. A reader needs a 3.14 python3 and nothing else; CI installs no package, which is what proves the stdlib-only half. Older interpreters are not a target: the /usr/bin/python3 that Apple's Command Line Tools install is 3.9.6, and some examples use f-string syntax that 3.11 rejects.
Rust: bare rustc --edition 2024. No Cargo, no crates. A lesson about something a crate does (unicode-segmentation, say) hand-rolls the narrow case in std and says plainly what the crate adds. The repo carries no Cargo.toml for the same reason, so RustRover opens every example under "Project not associated with a Cargo.toml file"; python3 tools/write_cargo_toml.py writes a gitignored one for your machine that lists every .rs file in the checkout, scratch files included, as a binary. It is the IDE's view and never the build — an example still has to compile on its own under the runner's rustc.
C: cc -std=c11 -Wall -Wextra, no libraries beyond libc, and only on a page where the C view sharpens the point — it is an aside, not a fourth track. cc is clang on macOS and gcc on Ubuntu; both compile in CI, so a warning from either is printed as a note and worth fixing.
Go: go build, one file at a time, stdlib only, no go.mod — an aside like C, for the pages where what Go itself does is the subject; rune is an int32 has the first two. Start the file with //go:build ignore, which keeps two examples in one folder from forming a package (both declare main) while go build file.go still builds the one it is named, and format it with gofmt. The runner builds from its scratch directory with GOTOOLCHAIN=local and GOWORK=off, so no module file and no toolchain download around the checkout can reach the build, and a missing go stops the run rather than skipping the example. CI installs the current stable Go on both runners through actions/setup-go — unpinned, for the same reason rustc is — so the rules below apply unchanged: record only behaviour the Go 1 compatibility promise ↗ covers, print an error's kind and never its text, keep %q and unicode.IsPrint to ASCII (both read the Unicode table), and never range over a map into a key, because Go randomises map order on purpose. Before recording, docker run --rm golang:1.N-alpine is to a Go example what python:3.N-slim is to a Python one.
Shell: bash, and only tools both macOS and Ubuntu ship — xxd, od, hexdump, cat, file, wc, printf, iconv, tr, sed, grep, find, sort, uniq, cut, strings. On Ubuntu xxd, hexdump, file and strings (binutils) are separate packages, which the examples workflow installs if the runner image ever stops shipping them; on a Mac strings comes with the Command Line Tools, like cc. CI runs every example on both, which is the only check that catches a BSD/GNU difference. Forty-one found so far. The first seven: od -a names bytes above 127 differently on the two platforms, and no layout helper can fix it — GNU masks the high bit off and names the remainder (c3 becomes C), BSD asks isprint() in the current locale and emits the raw byte when the answer is yes, so the row is both platform- and locale-dependent; record -tx1 and never -a, as Inspecting a file sets out. Then: od pads its lines on macOS and not on Linux (pipe through the tidy helper the existing scripts define); printf '\x..' is bash, not POSIX (so the scripts run under bash, and a page that wants portability shows the octal form); cat -A does not exist on macOS (cat -vet is the portable spelling of the same three flags, and its output is identical on both); and iconv -c repairs differently — on invalid input macOS iconv loses bytes that GNU keeps, so the same command writes two different files. Narrowed 2026-09-07 (nine inputs, both platforms): the two agree everywhere except one shape — when exactly one byte follows the skipped one, macOS discards it and GNU keeps it (61 e9 62 → 61 against 61 62; 61 e9 0a → 61 against 61 0a), while a two-byte tail survives on both, so the byte a real file loses is almost always its final newline. Plain iconv -f X -t X used as a yes/no validator agrees on both for UTF-8, exit status and all, which is what Validation is a boundary records — but not for a single-byte code page's unassigned bytes, which is finding 29 below. And iconv -t UTF-16 with no BE or LE picks the byte order itself — big-endian on macOS, little-endian on GNU — so the same command writes two different files and there is no key that matches both; name the order explicitly (UTF-16BE / UTF-16LE), as Byte order and the BOM does. And tr does not agree about whether the locale applies at all — BSD tr under a UTF-8 locale uppercases é to É, GNU tr is byte-oriented and has never handled multi-byte characters, so it leaves the same bytes untouched; there is no key that matches both, and Locale and LC_CTYPE states it in a dated table rather than running it.
Four more came out of 11_Tools on 2026-09-06 — unsurprising, since that chapter is about the tools whose job is not bytes, and those are the ones nobody standardised carefully. The first is the worst failure shape in this repo.
- BSD
grepin a UTF-8 locale DROPS a line it cannot decode, and exits 0. Three lines, all containingline, the middle one holding two invalid bytes: GNU grep 3.11 finds three, BSD grep finds two and says nothing at all about the third — no warning, no diagnostic, no status. GNU gives the same answer in both locales. So never record agreprun over invalid bytes, and in prose tell readers to search a file of unknown encoding underLC_ALL=C, where grep is a pure byte matcher and skips nothing. Measured ongrepon text that is not ASCII. Sharpened 2026-09-10 — the runs above used-a, and the default is not the same picture. Without-a, GNU grep in a UTF-8 locale also leaves the invalid line out, but it counts it (-csays 3) and writesbinary file matchesto stderr — its definition of binary includes output bytes that are invalid in the locale, not only a NUL — while BSD counts 2 with or without-aand says nothing. And BSD's loss depends on the pattern as well as the line: on the same filegrep bad, whose match is the line's first three characters, finds line 2, whilegrep line,grep ' 'andgrep 'bad.*line'do not. So the advice is now the only safe summary: search a file of unknown encoding underLC_ALL=C, where both greps print every line. Re-measured on Binary is a verdict, not a property. - The "binary file" notice has two wordings on two different streams — BSD prints
Binary file f matcheson stdout, GNU 3.11 printsgrep: f: binary file matcheson stderr. Redirecting a search therefore writes a junk line into the output file on one platform and produces a silently empty file on the other, exit 0 both times.grep -ais identical on both and is what an example should use. uniq -cpads its count to different widths, the same trap aswc -c. Strip it (sed 's/^ *//') before recording.- The filesystem, not only the tools. macOS APFS is normalization-insensitive, so creating
żółwin NFC and again in NFD leaves one file where Linux leaves two — and APFS refuses a filename that is not valid UTF-8 (Errno 92), where Linux takes any bytes butNULand/. An example that creates filenames may use only one spelling, and cannot test the invalid case at all. Measured onfind, and filenames that are bytes. Sharpened 2026-09-08, and "valid UTF-8" turns out to be the wrong description of the rule: APFS gives the sameErrno 92to a filename holding a noncharacter —EF BF BE,EF B7 90,F0 9F BF BEall refused, throughopen()and throughtouchalike — and those byte sequences are well-formed UTF-8 by every other measure here (Python encodes them,str::from_utf8accepts them, both iconvs pass them). A private-use character is accepted on both platforms; Linux accepts every noncharacter. So the check APFS applies is stricter than well-formedness, the errno is identical in both cases, and nothing in the message separates them. Measured on Noncharacters and the private use areas, which puts it in a dated fence for exactly this reason.
And a twelfth, from the tool that is otherwise the most portable thing in this list:
hexdump -cchanges notation with the locale on BSD and not on GNU. UnderLC_ALL=Cboth print octal (303 251for ané); under a UTF-8 locale macOS switches toM-xmeta notation for the bytes whose low seven bits happen to be printable —e2becomesM-b, while82stays303-style octal in the same row — and util-linux keeps printing octal. It is theod -amasking trick again, one tool along. Nothing else inhexdumpdiffers: the default view,-C,-x,-b,-o,-dand any-eformat are byte-identical on both platforms in both locales, down to the trailing spaces on a short last line, because util-linux's hexdump descends directly from the BSD one. Sohexdump -Cis the dump to record and to paste into a bug report, and-cis the one to leave out. Measured onhexdumpis a format engine wearing six presets.
And one that is not a BSD/GNU split but costs the same. xxd is a single implementation — it ships with vim, so both runners are executing the same program — and it still produced two different answers on 2026-09-07: xxd -e pads its short final group by one space more in xxd 2023-10-25 (ubuntu:24.04) than in 2025-11-26 (macOS). Every other flag in xxd is the dump you can put back is byte-identical on both. Nothing about the platform predicts this; the version does, and neither python3 nor rustc nor the vim on a runner is pinned here. So the rule that already applies to anything read out of the Unicode table applies to tool output too: if a column's width is the only thing carrying your claim, it is not a claim an answer key can hold. Put it in a dated fence naming both versions, as that page does.
-
printf '\u20ac'gives three different answers on three configurations, and only one of them is a euro sign. The escape that names a code point is the least portable thing in this list, because it depends on the bash version AND the locale: bash 5.2 underC.UTF-8writese2 82 ac; bash 5.2 underLC_ALL=Ccannot represent the character and hands the escape back with the hex uppercased (\u20AC); and macOS still ships bash 3.2, which predatesprintf's\uentirely and hands it back untouched (\u20ac). Six bytes either way and not the same six, so there is no key that matches both runners — record the shape (six bytes, not three) and put the bytes in a dated table.\xHHand\NNNname bytes, ask nothing of the locale, and are identical everywhere. Measured on Writing a code point. -
base64disagrees with itself in five places, and one of them is silent. The BSD build wraps nothing by default and the GNU build wraps at 76, sobase64 file > outwrites a different file on the two platforms; the flag that sets the width is-bon macOS and-won GNU;-Ddecodes on macOS and is an error on GNU; and-imeans input file on macOS and ignore garbage on GNU — the same letter, two unrelated jobs, neither of which errors. The silent one is the last: given a payload with a space in the middle, macOS decodes straight through and exits 0 while GNU emits partial output, saysinvalid inputand exits 1. Newlines inside a payload are accepted by both (MIME wrapped at 76, so every decoder skips them) and a short payload encodes identically, which is the only part an example may record. Measured on Binary to text. -
xargs -a filelistis a GNU extension, and BSDxargsrejects it outright —xargs: invalid option -- a, exit 1, so a sweep written with it runs nothing on a Mac. The portable spelling is the redirect,xargs < filelist cmd, which both accept. Worth knowing beside it, because they fail the same way:grep -l PAT $(cat filelist)puts every path in argv and blows pastARG_MAXsomewhere in the low tens of thousands of files, at which point the shell fails the command beforegrepstarts — no matches and nothing that reads as an error. Build the list, then pipe it throughxargs. Measured on The encoding man pages nobody opens. -
errnoafter a failedstrtolbelongs to the C library, not to the standard — and it is the first on this list that lives in a C library function rather than a command-line tool or the filesystem. On a string it cannot convert at all ("zz",""), macOS setserrnotoEINVALand glibc leaves it at0; both are conforming, because C leaves errno implementation-defined when no conversion is performed.ERANGEon overflow is promised and both agree, so that one may be recorded. The portable test for nothing was converted is the pointer —endptr == input— and a C example may print that comparison and must never printerrnoorstrerrorfor the no-conversion case (the message text differs too: Result too large against Numerical result out of range). Same genre as the CPython error wording that came off a key on 2026-09-06. Measured on Hex: a number, or a picture of bytes. -
diff's "binary" verdict reaches the whole file on BSD and the first block on GNU. Both call a file binary when they meet a NUL byte, and printBinary files a and b differinstead of the change — but a NUL 200 kB into an otherwise textual file makes macOS say exactly that while GNU prints an ordinary line diff; the boundary measured between 4 KB and 8 KB on diffutils 3.10. One log file with a stray NUL near the end therefore gets two different reports, both exit 1, neither of them wrong. An example may only put its NUL near the front, where the two agree. Measured ondiffcompares lines,cmpcompares bytes. -
cmp's two diagnostics differ and its main line does not.cmp -lpads the offset column to a fixed width on BSD and not at all on GNU — thewc -c/uniq -ctrap one tool along, so strip it withsed 's/^ *//'before recording — and the early-EOF notice iscmp: EOF on fon BSD againstcmp: EOF on f after byte 3on GNU. Both of those go to stderr, which is why only the padding can reach a key at all. What is byte-identical on both, and so recordable: thef g differ: char 4, line 1line — the wordcharis POSIX's own format string and both print it while counting bytes — the two octal value columns of-l, and every exit status. Measured on the same page.
Three more came out of od reads types, not bytes on 2026-09-07 — all inside od's own flags, which is unsurprising for the tool whose padding was already number two on this list.
- The offset column is a different WIDTH under
-A x. BSD prints seven hex digits, GNU six; under-A dand-A othey agree at seven. So the radix you most want for an offset is the one where the column moves. - The
ztype suffix is GNU-only.od -A x -t x1zadds a>text<column and makes GNUodvery nearlyhexdump -C; BSDodstops with "z: unrecognised format character". A script using it does not run on a Mac. -t fformats the same float differently. BSD prints-2.303804e+02where GNU prints-230.38042, with different precision in the exponent forms too. Same value, and nothing comparing the two as text will agree.
Between those and the padding, an od example can only be recorded through a helper that re-spaces its fields — which the od page's script does, in a section of its own rather than quietly.
And two more from File type is four questions on 2026-09-07, and neither is a BSD/GNU split — both are file(1) and cc being the same project at different versions, the genre the xxd -e note above already warned about.
file's English wording is version-dependent; its MIME output is not. One non-executable file holding#!/bin/sh, three builds, two answers: file-5.41 (macOS 26) saysPOSIX shell script text executable, ASCII text, while file-5.44 (Debian 12) and file-5.45 (Ubuntu 24.04) both sayPOSIX shell script, ASCII text executable— the wordexecutablemoved from one noun to the other upstream between 5.41 and 5.44, so macOS is simply behind rather than different. Checked across the same nine files,--mime-typeand--mime-encodingwere byte-identical on every one,inode/x-empty,inode/directoryandinode/fifoincluded. So an example may record the MIME forms freely and must never record the default prose; a script that greps the English is depending on a release note. (The rule already had a cousin:06_Terminal/file_guesseswas written as a stub because of this, and had guessed the reason correctly before anyone measured it.)- There is no spelling of the POSIX feature-test macro that gets
mkdtempunder-std=c11on both platforms. Compiled exactly as this repo compiles C, with no macro, glibc hidesmkdtempand gcc warnsimplicit declaration; add-D_POSIX_C_SOURCE=200809Land gcc goes clean but clang now errors, because macOS gates the same function behind its own_DARWIN_C_SOURCEand reads the POSIX macro as a request to restrict the namespace.-D_XOPEN_SOURCE=700fails the same way round. Two combinations do satisfy both (_DARWIN_C_SOURCE+_POSIX_C_SOURCE, and_DEFAULT_SOURCE+_POSIX_C_SOURCE), but the second works on macOS for no reason anyone here could explain, which is not a thing to build an answer key on. The fix is to not need the function:mkdir()plusgetpid()andsnprintf()builds a scratch directory with no feature macro anywhere, and compiles clean on both. Worth knowing generally — everything else that example calls (fork,execve,waitpid,chdir,chmod,unlink,rmdir) is exposed by default on both, somkdtempis the odd one out rather than the start of a pattern.
And three more, from the four tools nobody documents — split, paste, look and tee, measured 2026-09-07. All three are BSD/GNU splits, which is what the count above went up by.
-
paste -dis multi-byte aware on BSD in a UTF-8 locale and has never been on GNU.-dtakes a list of delimiters and cycles through it, and GNU reads that list a byte at a time in every locale, sopaste -d 'é' a b cputsc3after the first column anda9after the second — inLC_ALL=Cand inC.UTF-8. BSD does the same underLC_ALL=Cand treatséas one delimiter under a UTF-8 locale. Exactly thetrsplit one tool along, with the same conclusion: the C-locale answer is the one both give, so record that, and tell readers to use a single-byte delimiter. -
split's useful flags do not overlap, and the one that does rounds the other way.-C— a byte budget cut at a line boundary, the flag that would makesplitsafe on text — is GNU-only; BSD answersillegal option.-p PATTERNis BSD-only; GNU answersinvalid optionand offerscsplit, which both ship. And-n N, which both accept, divides the remainder to opposite ends: 17 bytes into 2 pieces is 8+9 on BSD and 9+8 on GNU, which on a file with a two-byte character at that offset is the difference between a piece that is still text and one that is not. The portable pair is-landcsplit. -
lookis not installed on a plain Ubuntu at all, so no example may call it —apt-get install bsdextrautilsbrings util-linux's. The two implementations also disagree on the case this library cares about: given a file sorted in a collation order rather than byte order, BSDlookreports nothing (exit 1) where util-linuxlookfinds the line. They agree on a genuinely unsorted file — nothing found, exit 1, for words that are plainly in it, which is the silent-false-negative shapegrepalready owns.
And a twenty-seventh, which is neither a tool nor a filesystem but a shell's exit status — the first on this list a page could have recorded without running anything that differs.
- A failed shebang exits 126 on Apple's
/bin/bashand 127 nearly everywhere else. Give a script a CRLF first line, so the interpreter path the kernel reads ends in a CR:execvereturnsENOENTon both platforms, and the shell turns that into a status. Apple's/bin/bashsays 126 (found, but not executable); bash 3.2.57 under Linux — byte for byte the same version — says 127 (not found), as do 4.4.23 and 5.2.37, and so do macOS's ownzsh5.9,fishanddash. Not the bash version, then, and not simply the platform: that one build. What is identical everywhere is the part worth teaching, and it is the sharper finding anyway — a script whose shebang the kernel never found at all (a BOM in front of it, or no#!) exits0, because the shell catches theENOEXECand runs the file itself. Record the zeros, derive a word for the nonzero case, and put the numbers in a dated fence. Measured on The first two bytes.
The shells' error text for that same file is worse, and falls under the diagnostics rule above: three shells give three sentences, and bash 5.2 on Ubuntu has dropped the interpreter path from the message entirely — so the ^M, the one character that explains the failure, is not shown to the reader most likely to meet it.
- The two spellings of
sed -iare mutually exclusive, so there is no in-place edit that runs everywhere except-i.bak. BSDsedtakes the backup suffix as a separate argument; GNU and busybox take it as an optional attached one. So baresed -i 's/a/b/' fworks on GNU 4.9 and busybox and fails on BSD with exit 1 — its error,sed: 1: "f.txt, is the giveaway that it has swallowed the script as its suffix and is now parsing the filename as the script — whilesed -i '' 's/a/b/' fworks on BSD and fails on GNU (exit 2) and busybox (exit 1), which read the''as the script and the script as a filename. Onlysed -i.bakruns on all three, and it leaves a.bakon all three. Measured on BSD sed (Darwin 25.6), GNU sed 4.9 (ubuntu:24.04) and busybox sed (alpine:3.20). What makes this one worth knowing rather than just avoiding is that it fails loudly: both wrong spellings exit nonzero and leave the file byte-for-byte unchanged — no half-edit, no truncation. On a list where the recurring shape is a silent wrong answer, this is the counter-example, and it is why a build script that checks its exit status is safe here in a way it is not withgreporbase64. (Found by the crlf_vs_lf session; reproduced here on all three implementations before recording.)
And two more from the mojibake spine of 07_Real_Data, measured 2026-09-07. Both are iconv, and together they say the same thing twice: the name of a code page is not the code page.
-
iconv -f X -t Xis not a validity test for a single-byte table's unassigned bytes, and it fails in the quiet direction. Windows-1252 assigns 251 of 256 byte values; the five it leaves empty are0x81 0x8D 0x8F 0x90 0x9D. Given a file holding one of them,iconv -f CP1252 -t CP1252exits 0 on BSD and passes the byte through unchanged, and exits 1 on GNU. Counted one byte at a time, BSD's CP1252 accepts 256 of 256 and GNU's accepts 251 — the two builds disagree about what the table contains, so the idiom the rule above blesses reports a file clean on one runner and broken on the other. The same split holds for CP1250's0x81and CP1253's0xAA, so it is not peculiar to one table. Convert to UTF-8 instead (iconv -f CP1252 -t UTF-8), which exits 1 on both. Two limits on that, because both are easy to overstate. It is not a general claim that BSD iconv skips validation —iconv -f UTF-8 -t UTF-8over invalid UTF-8 correctly exits 1 on both platforms, which is why finding 7's sentence stays true for UTF-8 and whyvalidation_is_a_boundaryis unaffected. And-t UTF-8is not universal either: on CP874's0xDBthe two builds still disagree, BSD converting it happily and GNU refusing, because there the tables differ in content rather than in how the conversion is driven. When the answer has to be right, decode it in Python, where the table is the documented one and the same on every machine. Measured on Windows-1252 vs Latin-1. And the same split reaches ASCII, which is the table nobody thinks to check (measured 2026-09-07 on the same two builds): on the bytes61 e9 62,iconv -f US-ASCII -t US-ASCIIexits 0 on macOS and writes61 3f 62— substituting a question mark — where glibc exits 1 and writes61. Across six inputs macOS returns 0 for every one where the invalid byte is not the entire file, so the idiom passes a non-ASCII file as clean on a Mac. The remedy above holds and was found independently here:-f US-ASCII -t UTF-8was identical on both platforms on every input tried. Onfileguesses. -
BSD iconv has none of the euro-updated EBCDIC code pages, and the three tools that know EBCDIC know three different subsets of it. IBM published a second CCSID for each EBCDIC table when the euro arrived —
037→1140,273→1141, …871→1149, ten pairs differing in a single byte. BSD iconv ships all ten of the pre-euro pages and not one ofCP1140–CP1149; GNU ships all ten euro pages and every pre-euro one exceptCP277; Python 3.14's standard library ships a third subset again —cp037,cp273,cp500,cp1140, pluscp424,cp875,cp1026. So a shell example may useCP037andCP500and nothing else in that family, and the euro twin can only be demonstrated in Python. The general form is worth more than the list: a code-page number is only as real as the catalogue of whatever is about to read it, and asking three tools on two machines about the same ten numbers produced three different answers. Measured on SAP code pages.
And a thirty-first, which is the only one on this list where the two platforms first had to be caught AGREEING. Collation, measured 2026-09-07 while writing Sorting and collation.
en_US.UTF-8orders punctuation three different ways on three builds, while every letter rule is identical. Sortingab,a-b,a b,aa: glibc 2.27 puts punctuation after the letters, glibc 2.39 makes it ignorable at the first level, macOS 26.6.2 puts it before — sostrcoll('a-b','ab')changes sign between the two glibcs, which is the change that shipped in glibc 2.28 (2018-08-01, the ISO 14651 2016 update) and silently invalidated PostgreSQL B-tree indexes on every distribution that took it.LC_ALL=Cgives one answer everywhere. Measured twice, throughlocale.strxfrmand throughsort(1), which agreed on every row. What makes this one worth writing down is the half that did NOT differ: the same three builds produced five locales' worth of accented-letter and contraction rules — Polishóas a letter, Czechchafterh, SwedishÖafterZ, DanishAaat the end — byte for byte identically, because glibc's ordering comes from ISO 14651 and macOS's derives from the same CLDR-shaped source. So the intuition to correct is the natural one: the exotic letters everybody worries about are the part that has converged, and the ASCII punctuation nobody tests is where the platforms and the versions still disagree. Two consequences for this repo: no example may record a collated sort — the locales are not installed on both runners anyway (ubuntu:24.04shipsC,C.utf8andPOSIX, andlocale.setlocaleraises rather than falling back), which is why that page's example asks for no human locale at all and its comparisons live in dated fences; and anything persisted in collated order carries a dependency on a version number nobody wrote down.
And two more from the terminal chapter's backlog, measured 2026-09-07. Both are the list's recurring shape — the wrong flag answers instead of erroring — and the second is why the page that found them had to do its measuring in Python.
-
file -iandfile -Iare swapped, and only one of the two mistakes says so.--mime-type/--mime-encoding/--mimeare spelled the same everywhere; the short forms are not. On Ubuntu,file -Iis loud —file: invalid option -- 'I', exit 1. On macOS,file -iis silent: it means do not look inside, so it answersf: regular file, exits 0, and a script grepping forcharset=finds nothing and takes itselsebranch. Same genre as finding 14'sbase64 -i, and as finding 29 one tool along, and the polarity is the bad one — the quiet failure is the one on the laptop. The split itself was already named in prose on Inspecting a file; what is new is that half of it produces an answer rather than an error. Measured on file-5.41 (macOS 26.6.2) and file-5.45 (ubuntu:24.04). -
script(1)— the only portable-looking way to give a command a pty — has two incompatible command lines. BSD wantsscript -q /dev/null CMD ARGS…and GNU's util-linux wantsscript -q -c 'CMD ARGS…' /dev/null; each rejects the other outright (script: unexpected number of arguments, exit 1;script: illegal option -- c). So a shell example cannot arrange for its own output to be a terminal on both runners, which is why A pipe is not a terminal drives both branches from Python'sptymodule instead.stdbufis not a substitute: it changes a child's buffering, not what fd 1 is, and it cannot touch a program that sets its own — which Python does.
And a thirty-fourth, from UTF-7, and the seven-bit transport, measured 2026-09-08. It is the list's most familiar shape — the wrong answer, silently, exit 0, on the laptop rather than the runner — arriving in the one place a round trip looks like it must be safe, because both halves of it are the same program.
- BSD
iconvdoes not absorb a UTF-7 shift sequence's terminating-at the end of its input, so it cannot round-trip its own output. RFC 2152 ↗ is explicit that a-which terminates a modified-Base64 run is absorbed and produces no character. GNU iconv (glibc 2.39,ubuntu:24.04) does that everywhere. BSD iconv (Darwin 25.6.0, macOS 26.6.2) does it everywhere except at the end of the input, where it emits the byte2das a character:caf+AOk-decodes to six bytes (63 61 66 c3 a9 2d) on the Mac and five (63 61 66 c3 a9) on Linux, and+ADw-script+AD4-gains a trailing-the same way. Since BSD iconv's encoder always writes that terminator,iconv -f UTF-8 -t UTF-7 | iconv -f UTF-7 -t UTF-8fails to return its own input on macOS — and both halves exit 0. Measured over six inputs in both directions; a terminator anywhere but the last position is handled correctly on both builds, which is precisely why casual testing misses it, since the affected character is the final one in the file. So no example may record a UTF-7iconvrun, and that page has no## In the terminalsection for exactly this reason. Python and Rust agree with GNU here, so the odd build is unambiguous rather than a coin toss between two readings.
And a thirty-fifth, from The shell has no string type, measured 2026-09-08. It is the first on this list whose subject is the interpreter rather than a tool it runs, which is why it reaches three unrelated constructs at once.
shis not a program, and the difference reaches the shell's own string handling — so a#!/bin/shscript gets two answers with nothing in the script, the file or the locale changed./bin/shis bash 3.2.57 on macOS and dash on Ubuntu, and bash grew multi-byte support where dash never has. Sov=$(printf '\303\251'); echo ${#v}prints 1 on a Mac and 2 on Ubuntu in a UTF-8 locale, and 2 on both underLC_ALL=C— the Mac being the one that counts characters, which is the opposite of the direction most people guess. Two more of the same shape came out beside it.echo -n hiwriteshiunder bash, dash and zsh and-n hiplus a newline under bash-invoked-as-sh, which enters POSIX mode and stops recognising options — the exact mirror of theecho '-n'failure onprintfwrites bytes, since that same/bin/shis then the only shell measured here that can print the string-n. And$'…'is not a quoting form in dash at all:$'caf\xc3\xa9'yields a literal$and the backslashes, twelve bytes instead of six, no error and no status. The portable spellings areprintfwith octal escapes, which gave identical bytes in all seven shell-and-platform combinations tried, andprintf '%s' "$v" | wc -cfor a length. A second, unrelated split lives one call along and is a bash version rather than a shell identity: given a file holding61 00 62,IFS= read -r vleaves61on bash 3.2 and6162on bash 5.2 — truncate against drop, two different wrong answers to one call, and distinct from the$( )warning split that page already records, which is about the diagnostic and not the value. zsh is the counter-example that bounds all of it: its variables are counted rather than NUL-terminated, so it holds all three bytes through both the assignment and theread, and still hands61to the next program, because that boundary belongs toexecve. Measured on The shell has no string type.
And a thirty-sixth, measured 2026-09-10 while writing Binary is a verdict, not a property — the printf nobody types.
/usr/bin/printfon macOS knows neither\xHHnor\uHHHH, and refuses neither: it drops the backslash and prints the rest. Every shell a Mac user types into hasprintfas a builtin — bash, zsh and fish all writec0forprintf '\xc0'— so the binary is reached only by what runs a program:env printf,xargs printf,find -exec printf. There the two platforms part. BSD writesxc0, three printable ASCII bytes, andu00e9for the code-point escape, with exit status 0 and nothing on stderr; GNU coreutils writesc0, and for the code point does what bash 5.2 does —c3 a9in a UTF-8 locale, the escape handed back uppercased underLC_ALL=C. So a script that builds its test bytes throughxargs printfwrites two different files on the two runners and succeeds on both. Octal is the fix here as everywhere:printf '\300'wrotec0in every implementation measured, builtin or binary. The table is onprintfwrites bytes.
And three from strings has a printable set, not an encoding, measured 2026-09-10. POSIX specifies strings and leaves three things to the implementation — which part of the file, which bytes count as printable, and what the locale has to do with it. The two builds differ on all three, and on one thing nobody left to anybody.
stringsreads a different part of the file by default, and-ais a different flag on each platform. GNU has scanned the whole file by default since binutils 2.25 and keeps-dfor the old data-sections-only scan. Apple's default reads an object file's sections except(__TEXT,__text); its-aadds that one section; and only-reads every byte — which is what POSIX says-ameans ("Scan files in their entirety"). So a one-line C program gives 60 lines on Debian and none at all on a Mac, exit 0 both, andstrings -aasks for the whole file on one platform and for one more section on the other.-e(character width) and-U(UTF-8 handling) are GNU-only, and Apple refuses them loudly —unknown flag, exit 1.stringsdisagrees about which bytes are printable, and Apple's disagrees with itself. GNU counts a tab; Apple does not. Apple's file-argument path adds form feed and ignores the locale, while its stdin path asks the locale about each byte separately — so in a UTF-8 localestrings fandcat f | stringsdiffer on any non-ASCII text, and the stdin path, judging each byte as if it were a Latin-1 character, hands back half of a valid UTF-8 character (żółwbecomesc5 bc c3 b3 c5). An example feeds it stdin, underLC_ALL=C, with no tab — where the two builds agree byte for byte.- Apple's
strings FILEprints the file's last byte when it ends a run.printf 'world\377' > f; strings fwrites77 6f 72 6c 64 ff 0a— anffthat no table on the page calls printable — whilestrings < fon the same Mac, and GNU either way, writeworldalone. It needs a run of four before it (wor\377prints nothing), and a trailing newline hides it, so it survives in exactly the file a first experiment builds: some text, some junk, no newline.
And two from Which base did you mean? on 2026-09-12, both about the base a field reads its digits in. The page's whole subject is that nothing in a run of digits says which base it is in, so it is unsurprising that the two platforms answer differently somewhere; what is surprising is where, since xxd -s, od -j, head -c, sort -n, test, [[ ]] and bash's printf were byte-identical on both.
dd's numeric operands are read in a different base on each platform, in both directions, and the silent one reads a different part of the file. Over one 20-byte file:dd skip=010lands on byte 8 with BSDdd(macOS 26) and byte 10 with GNU coreutils 9.4 — C's rules against base 10, no diagnostic from either — anddd skip=0x10lands on byte 16 on the Mac while GNU readsxas its own multiplication suffix, making it 0 × 10 = 0: it copies from the start of the file, warns'0x' is a zero multiplier; use '00x', and exits 0. So oneddcommand reads two different regions of one file on the two runners, and the leading-zero case does it without saying anything. Noddexample may record askip=,seek=,bs=orcount=written with a leading0or0x; write the plain decimal, which both read the same way. (xxd -s,od -jandhead -call agree across platforms, so a page about offsets can use those instead — and they do not agree with each other, which is that page's subject rather than a portability problem.)socket.inet_ptonaccepts a leading zero on macOS and refuses it on glibc, so the strict IP parser is the platform's and not Python's.socket.inet_pton(AF_INET, '010.0.0.1')returns10.0.0.1on macOS 26 — read as decimal, no complaint — and raisesOSError: illegal IP address stringunderpython:3.14-slim, on the same CPython 3.14.7. It is finding 16's shape (errnoafterstrtol) one function along: the wrapper is Python's, the parser is libc's. Nothing in the module's own behaviour is at fault, andsocket.inet_atonis identical on both (010.0.0.1→8.0.0.1, octal, asinet_aton(3)documents), which is what makes the split easy to miss: the lenient function agrees and the strict one does not. Useipaddress.ip_addressfor anything an answer key or a security check depends on — it is CPython's own parser, refuses all of these everywhere, and says in its source that it was written to be "as strict as glibc's inet_pton()", which is precisely the build that disagrees with the Mac.
A note on the count, because the next person to find a composite will reason their way to inflating it. The number claims distinct measured splits, so a finding that is two existing rules composed does not earn a new one — it earns a cross-reference. The case that set this: the shebang error message differs across shells and drops the interpreter path in bash 5.2, which is finding 27 plus the diagnostics rule above, and adding it as a 28th would have made the count mean less rather than more.
Deterministic. No clocks, no randomness, no network, no reading the filesystem. Every example runs under a fixed environment (LC_ALL=C, PYTHONUTF8=1) so the key does not depend on who ran it — and for a Python example it is the locale that does the work: the runner starts it as python3 -I, which ignores every PYTHON* variable, and PEP 540 switches UTF-8 Mode on under a C locale by itself (Opening a file measures both halves); a lesson whose subject is the locale sets its own inside the script, on purpose and in view. And nothing read out of the Unicode table may become a key. rustc is not pinned here and upgrades on its own schedule, and python3 is pinned only to a minor version, 3.14 on both runners, so its table moves the day that pin does and a reader's Python need not match it at all — on the machine this rule was written, python3 was answering from Unicode 16.0 and rustc from 17.0, in the same repository. A name is safe (Unicode guarantees it never changes) and arithmetic over the number line is safe; a count of assigned code points, or whether some recent character is assigned at all, is a fact about the toolchain that answered, and breaks when that toolchain moves. The table has a version works through which is which, and its two programs are written to print questions where the answer would have been a version. And a stdlib function in a module pinned to the frozen table is not therefore a frozen answer. CPython's stringprep opens by pinning ucd_3_2_0, yet its case fold falls back to the live str.lower(), so on 3.14.7 map_table_b2('ẞ') is 'ss' for a letter the same module's in_table_a1 calls unassigned — read the source before you treat an output as frozen; inspect.getsource(stringprep.map_table_b3) shows the fall-through in four lines. And the fix, gh-155292 ↗, is backported as far as 3.12, so that answer changes in a patch release: every Python 3 agrees is a claim about the interpreters released so far, and has to be re-checked when an upstream fix lands. The step the pin did not reach has the mechanism and the measurement.
Prose inside an example is not checked by anything, and it is where wrong claims hide. The answer key proves the program printed what the page shows; it cannot know whether a sentence the program prints is true. So an explanatory line in a print needs the same evidence as a number, and it is the easiest place in the repo to assert something plausible and unverified. Four instances on 2026-09-07, across two pages and two sessions, and they take three different shapes — which is why this is a rule rather than a checklist item about one mistake.
- Inverted. The trailing-newline Rust example asserted stdout "is line-buffered when it is a terminal": Rust wraps stdout in a
LineWriterunconditionally, and it is C and Python that switch to block buffering off a tty. The claim named the right subject and got the direction backwards. - Overclaimed. The CRLF example inherited the convention from "every protocol written in the 1980s" —
everyis unearnable, and HTTP, which the same page names in the same breath, is a decade later. - Self-contradictory, inside one key. The same CRLF Python example said bytes are what you get from "a socket, a zipfile, a subprocess with
text=False, and a database column", while section 6 of that same program correctly said those paths translate nothing. ATEXTcolumn hands youstr(sqlite3, measured); the other three do hand youbytes. So the accurate sentence sat eighty lines below the wrong one, in one recorded key, both inside the same fence and both looking equally verified. - Survived by luck. The first-two-bytes example asserted what
file(1)calls its four scripts. True — and it survived the file-5.41/5.45 wording split only because it counted rather than quoting. One notch more specific and it would have been a red CI run.
That third shape is the argument in miniature: the numbers in a generated block are evidence and the prose beside them is assertion, and they sit inside the same fence looking identical. All four had been read many times.
And the audit is worth running even when nothing is false, because it finds a second thing: a claim that is correct but inferred, standing in for evidence that was one command away. The file-type page argued that file(1) runs its test classes in order and stops at the first hit — reasoning backwards from the answers, since an empty file reports inode/x-empty. True, and strace settles it outright and more strongly than the inference could: on an empty file file issues newfstatat and no openat at all, so stage two does not lose the race, it never runs (measured on ubuntu:24.04, and the same trace shows the openat appearing for a non-empty file). A false sentence is the worse defect; an unevidenced one is the commoner, and the same pass over your own printed prose is what turns up either. When a claim in a print statement is doing teaching work, measure it, and if the measurement is platform- or version-dependent put it in a dated fence on the page rather than in the key.
And an interpreter's — or a compiler's — diagnostic TEXT is not a property of your data. Same rule as the paragraph above, one layer out, and it broke CI twice on 2026-09-06/07. os.stat('a\x00b') raises ValueError with two different sentences on macOS and Linux; bytes.fromhex('123') raises ValueError with two different sentences on CPython 3.13 and 3.14. The second is the instructive one, because it is a version split rather than a platform split — running the example on both runners would not have caught it if the two happened to ship the same Python. So print the exception class, and say in the program's own words why the call refused; if the wording is the point, put it on the page in a dated fence naming the builds it came from. The same rule catches a shell, and that is a cross-reference rather than a new number, because only the diagnostic moves. v=$(printf 'a\000b') drops the NUL on every bash there is — 61 62 comes back on 3.2.57, 4.4.23 and 5.2.37 alike — but 4.4 and later print warning: command substitution: ignored null byte in input on stderr and 3.2 says nothing at all, so the silent build is macOS's. Note where the redirection has to go: the warning is emitted while the substitution is being expanded, so 2>/dev/null on the assignment does not catch it and exec 3>&2 2>/dev/null around the line does. On printf writes bytes. A compiler is no steadier, measured 2026-09-08: the &s[0..4] char-boundary panic changed wording twice in consecutive rustc releases — 1.95 added the start/end prefix, and 1.96 dropped the sliced string itself, so what 1.75 through 1.94 word as byte index 4 is not a char boundary; it is inside 'é' (bytes 3..5) of `café` reads on 1.96 and later as end byte index 4 is not a char boundary; it is inside 'é' (bytes 3..5 of string). Neither runner pins rustc, so a key holding that sentence would have been green on the machine that wrote it and red on a runner one release either side. Eight versions under docker run --rm rust:N-slim settled it in minutes — rust:N-slim is to a Rust example what python:3.N-slim is to a Python one, and the second wording change is worth knowing for its own sake, since the older message copies the string being sliced into the panic and thus into your logs. Measured on Slicing by byte, which records PANICKED and an is_char_boundary column instead of the sentence.
Written to be read aloud. Numbered sections, aligned columns, prose in the print statements. A reader should understand the output without the page and the page without the output.
A snippet in the prose puts its output in a trailing comment, on the line that prints it, so the whole thing survives a copy-paste into a file:
Never open a page with code that does not run. The first block on a page is the one that gets pasted. Lead with the working thing; put a refusal or an error further down, as a comment inside a valid snippet or as a text fence nobody can paste into a program by accident.
A fence title never holds a backtick — name code bare, as every title here has since the 2026-09-10 fix, because GitHub does not read a ``` line whose info string contains one as a fence (its closing ``` then opens a block that swallows what follows) and mkdocs build --strict fails on it; a ~~~ fence is the fallback if a title truly needs a backtick, since every fence-parsing tool in this repo accepts ~~~.
Bridges¶
Every lesson has a section If you are coming from Python or ABAP. Those are the two languages this library's reader already thinks in, and a bridge to a language you already speak is the fastest teaching on the page — take the words it needs. Say what transfers and what the new language enforces that the old one left to habit; a bridge that hides a real difference costs more than it saves.
The ABAP half is prose. CI cannot run ABAP, so every page says so in the bridge: (Not machine-checked — CI cannot run ABAP.) Keep ABAP claims to things you would bet on — type widths, xstring vs string, the cl_abap_codepage and cl_abap_char_utilities names — and never quote an SAP code-page number without saying it should be verified against the system.
Try it, and Practice¶
## Try it closes a lesson. Three to five numbered prompts, each one something the reader runs against their own files — the CSV that came out wrong, the filename find cannot see, a script they already have. 68 of the library's 71 finished lesson pages end with one; the three that do not are a survey (worth_installing), a resource page (anki), and a page that is already an exercise end to end (tribit). Treat it as required unless your page is one of those shapes.
## Practice is optional, and it is a different thing. It holds a kata: predict the answer, then run it, then check. It goes after ## Try it and before ## See also.
The test for which section a prompt belongs in is whether you can print the answer:
- "Run
fileon the worst CSV you have, thenhead -c 3 | xxd" has no answer — the answer is on the reader's disk.## Try it. - "Write down the hex of these six writes before you run any of them" has exactly one answer, and it is the same on every machine.
## Practice.
Both failure modes are quiet. A kata with no checkable answer is a chore the reader abandons; a Try it with an answer printed under it is a claim about a file nobody here has seen.
Fold the answer, and put markdown="1" on the tag:
<details markdown="1">
<summary><strong>Answers</strong></summary>
**The six writes.** `68 65 6c 6c 6f` is `hello` …
</details>
That attribute is load-bearing and its absence is invisible from the author's chair. md_in_html is enabled, so without markdown="1" the body ships as literal Markdown — asterisks and backticks drawn on the published page — while GitHub renders the same block correctly either way and mkdocs build --strict passes, because it is not a link error. Measured 2026-09-07: the library's first kata shipped exactly like that, and the only surface showing the bug was the site. Do not reach for a ??? Material admonition instead; that one prints as literal text on GitHub, which is the mirror of the same problem.
An answer has to have been run — required, not preferred, and machine-checked. The solution goes in examples/<stem>_kata_sh.sh beside the lesson's own example and is pasted into the fold with <!-- output:<stem>_kata_sh -->, so the answer key runs in CI on both platforms with every other example and a solution cannot rot into one that no longer prints what the page says. The same rule the sibling Rust library ↗ keeps, for the same reason.
Two things a kata's own example has to pin, because an answer key that is not the same twice is not an answer. Anything the reader's machine supplies — $USER, $HOME, a hostname, a date — must be set inside the script, and the page must say so; the first kata's fifth line expands $USER on purpose, so the key pins it to ada and says which row will not match. And anything the two platforms spell differently, stat above all: -f is the format string on BSD and show the filesystem on GNU, and both succeed, so a fallback pair is silently wrong on Linux. Run the script under Docker against ubuntu:24.04 and diff it against your own run before recording the key — CI checks both, and a key that matches one of them is worse than no key.
python3 tools/check_katas.py # six rules, all of them from this section
python3 tools/check_katas.py --selftest # prove the gate still bites
It reads prose only. Fenced blocks and inline code spans are stripped first, so a page may show a malformed fold as an example, or name the tag in a sentence, without failing its own gate — which is what this section does twice.
A kata lives on the page for the topic it teaches, never in a folder of its own and never with a number in its heading. Folders are permanent URLs and a sequence is the thing that gets reordered — the same reasoning as Nav order below. The sequence lives in KATAS.md, a table that costs nothing to reshuffle, and a new kata needs a row there: the index is the one file no lesson owns, so nothing about your page can reveal that its row is missing. check_katas.py fails a ## Practice with no row, a row pointing at a page with no kata, a row whose links do not resolve, and numbering that has stopped reading K1, K2, K3 in table order.
Do not print the kata's number on its own page. Open with a short bold title instead. The number lives in that one table, which is what makes reordering free; a K7 in a page's prose is a second place to update and the reason a stale one goes unnoticed.
A stub gets neither section. It has no example behind it, so a Try it would point at nothing and a folded answer would be a guess with a disclosure triangle over it.
The cast¶
Demonstrate with a character from CAST.md. Nine characters, seven invisibles and seven strings, each earning its place by a property no other member has — é for mojibake, ż for what a Latin-1 table cannot hold, € for Windows-1252, 😀 for the BMP boundary, ß for case mapping that changes length, Łódź for where the two Polish code pages part company, café against café for normalization.
The reason is compounding rather than tidiness: a reader who has already met é knows it is C3 A9, one byte in Latin-1, and é when the two are confused, so your page can spend its words on its own subject. When the measurement was taken, 90 of the library's 156 distinct non-ASCII characters appeared once or twice in the whole repo — that tail is what the cast replaces.
If no cast member has the property your page needs, use what you need, say in a line why, and add a row to CAST.md if it will be wanted again. Its byte columns are generated from a program, so a new row goes in that program too.
A string you call decomposed has to be decomposed — machine-checked (python3 tools/check_decomposed_literals.py). café and café are the same picture, so nothing but a program can tell them apart, and the odds run one way: you cannot type a decomposed string. Keyboard, editor and clipboard all hand you the composed spelling, so a hand-written "decomposed" example is composed unless somebody deliberately pasted the mark. When the check was written, all three of the library's hand-authored decomposed literals were composed, each sitting under counts (6 bytes, 5 code points) its own string does not produce. Paste the real spelling — python3 -c "import unicodedata as u; print(u.normalize('NFD', 'café'))" — or write the mark out (U+0301, \u{301}, 65 cc 81), which is the better answer inside a fence where a bare mark is invisible to the author too. Generated blocks are exempt: their numbers come from the program that printed them.
Stubs¶
A stub is a lesson page with no example behind it yet: an H1, a **Level:**, the notice, a **One line:**, and the questions the finished page has to answer. It exists so the plan has a shape and every page has its permanent URL before the prose does. Every stub carries this notice directly under its **Level:** line:
> **Stub — an outline, not a lesson.** There is no runnable example behind this page yet, so nothing on it has been through [the check that backs every other claim in this library](../../CONTRIBUTING.md). The bullets below are the questions the finished page has to answer.
A stub must not have an <!-- output: --> block — there is nothing to fill it from. It graduates by gaining an examples/ program and losing the notice; update its row in the chapter README and in ROADMAP.md when it does.
That notice is the machine-readable half of the claim — python3 tools/check_chapter_status.py reads it off every page and holds three indexes to it: the chapter's own | # | Lesson | … | Status | table, ROADMAP's status row, and the Written / stub counts on the chapter map. So a page that quietly graduates fails the gate in three places rather than going stale in three places, and --selftest plays that exact scenario out — one stub loses its notice, nothing else is touched, and each of the three has to complain on its own.
The counts on the map are generated; run --fix rather than typing one. Everything else the gate checks is a row you write, and it will not invent those. The distinction is the finding the gate came out of. On 2026-09-07 every hand-typed count in the library was wrong — all eight cells of the map (3 / 0 for a chapter with seven written lessons), and ROADMAP's four "the other three pages" rows, two of which were four pages and one of which called a written lesson a stub — while every surface with one row per page was correct: 14 chapter tables, 76 ROADMAP rows, all of KATAS.md. A row goes stale only when you edit the thing it describes, and you are already there; a count goes stale when you edit something else entirely, which is every other day. So prefer a row, and where a count genuinely reads better — the map is a map, and eight numbers say something eight tables do not — generate it.
Links¶
- Link a folder by naming its
README.md—[label](some_folder/README.md), never[label](some_folder/), whichmkdocs --stricthas failed since 2026-09-11. - A repo path in backticks should be a link, not bare code text: backticks in the label, a real relative path in the href.
- A link that leaves the library ends its label with
↗; an internal link never does.python3 tools/check_link_style.py --fixadds and removes them; CI runs it without--fix. - A passing
link styledoes not mean your links resolve.check_link_style.pychecks the↗convention and nothing else (not the two shapes above either); it ismkdocs --strictthat fails on a link whose target does not exist, since 2026-09-10 on one whose#fragmentnames no heading on the page it points at, and since 2026-09-11 on one that names a folder. Measured 2026-09-07: a[label](./this_file_does_not_exist.md)planted in a page passeslink styleand failsmkdocs --strictwith "the target … is not found among documentation files". Two different checks over the same syntax, and the one that answers "is this link real" is the slow one at the end — so a greenlink styleon its own is not a reason to skip the full run. The fragment half is a setting, not a default. MkDocs 1.6 already checked fragments against the ids of the page they point at, and reported a miss atinfo, which--strictdoes not count: on 2026-09-10 a../preparing_a_string/README.md#no-such-headingplanted in a page built green, its verdict one INFO line in the log.validation: links: anchors: warninmkdocs.ymlfails the same build with "… does not contain an anchor '#no-such-heading'"; all 206 of the library's fragment links passed it the day it went on. And so is the folder half. A folder link names no file, so MkDocs never resolves it and never reaches its fragment: on 2026-09-11../preparing_a_string/and../preparing_a_string/#no-such-heading, planted as links, both built green, each an "unrecognized relative link" atinfo, andlink stylepassed them too.unrecognized_links: warnbesideanchors:fails both with "… it was left as is. Did you mean '../preparing_a_string/README.md'?", with or without-v, and no link in the library tripped it the day it went on. Two things the fragment check still cannot see.-vswitches it off: MkDocs records fragment links only when logging is above DEBUG, so the planted link passesmkdocs build --strict -v. And the ids are the site's, not GitHub's: a heading with—in it is#a-bon the site and#a--bon GitHub, so a link to it resolves on one surface only. Measured against GitHub's own renderer on 2026-09-10, 116 of the library's 1,485 headings had two ids, 109 of them through a—, and one of the 206 fragment links landed on one: MSB and LSB, which traded its dash for a colon on 2026-09-11 and kept its site id. Both are a second gate's job, for any link written in Markdown.tools/check_fragments.pyreads the Markdown rather than MkDocs' log, so-vcannot hide a fragment from it, and it holds every fragment to both rules: the heading it names on the site must carry the same id on GitHub. Planted on 2026-09-11, theREADME.mdlink above fails it at any logging level, and so does a link to the title of--preand-zby its site id,#-pre-and-z-decompress-then-decode, which--strictbuilds green. When it fails, reword the heading until the two ids agree —:in place of—leaves the site's id as it was, so no link that works on the site today breaks — or link a heading whose ids already do. Its docstring has both rules as measured, including the two details in which GitHub departs from html-pipeline's TocFilter as that is usually described. - Where the sibling Rust library already teaches something —
u8, hexadecimal,char, the anatomy of aString— link to it and do not repeat it. Its pages publish athttps://masiarek.github.io/rust-learning-library/<folder>/index.html; a folder README isindex.html, neverREADME.html.
Nav order¶
Sidebar reading order lives in NAV_ORDER in mkdocs_hooks.py, keyed by folder path. Never set order by renaming files to 01_, 02_ — a filename is a permanent URL. Unlisted pages sort alphabetically at the bottom, so adding a page needs no edit there; a new chapter does. A lesson's sidebar label is its README's # H1, up to any — subtitle and with the backticks dropped, so write the label there; LABEL_OVERRIDES holds the exceptions — 11_Tools lists tools by name, and an H1 that is a whole sentence gets a short label.
Reading order has two renderings, and only one of them is the sidebar. The other is the prev/next arrows at the foot of every page, and they are computed at a different time: MkDocs sets previous_page / next_page inside get_navigation(), which runs before any hook, so a hook that only sorts nav.items fixes the sidebar and leaves the arrows in MkDocs' default alphabetical order. That is what happened here — for 13 chapters the published 11_Tools page offered "awk" as the page after it while the sidebar said "grep" — and nothing caught it, because each half is internally consistent and the two are only comparable side by side. on_nav now re-chains the arrows after sorting, and tools/check_nav_chain.py asserts the two agree — forward, backward, and in nav.pages — walking the nav itself rather than reusing the hook's helper. If you change on_nav, run that gate; it fails loudly when the re-chain goes missing, and --selftest proves it still bites.
The same gate checks that NAV_ORDER and LABEL_OVERRIDES name things that exist, because a name the hook cannot match is a silent no-op: the page drops to the alphabetical tail and nothing is printed. That fires two ways. A rename left the entry behind — update it. Or the entry is simply ahead of its page, which in a shared checkout is the common one: git add mkdocs_hooks.py takes whatever a colleague has left in the file, including a row for a folder they have not committed yet. Commit the row and its folder together. Note that mkdocs build --strict passes in that state — a stale nav name is not a broken link — so this gate is the only thing that catches it.
A third rendering has numbers on it: each chapter README's | # | Lesson | … | Status | table, which tools/check_chapter_status.py holds to NAV_ORDER's order — on 2026-09-08 one listed two new 02_Characters lessons after logical_and_visual_order while NAV_ORDER put them straight after a_code_point_is_not_a_character, and all eleven gates passed until somebody read the two side by side.
If someone else is working here too¶
Give each concurrent worker its own worktree. Everything in the next two sections is a mitigation for sharing one; a worktree is the prevention, and it is one command:
A shared checkout means one working tree, one index and one HEAD, and its failures are quiet rather than loud. Observed here on 2026-09-07, with three workers in this repo for an afternoon: a whole lesson directory deleted from the tree with the deletion unstaged; a committed paragraph reverted inside a file that still read correctly, visible only as 0 2 in git diff --numstat; a push that carried another worker's commit; and a false green in a gate written that same day to catch exactly this. None of them was caught by a gate — each was caught by somebody re-running a claim. The gates check what the code does; nothing checks what a shared tree did to your files while you were not looking.
And it repairs a gate mode rather than only avoiding damage. A worktree has its own index — .git/worktrees/<name>/index, not .git/index — so a colleague's git add cannot enter your staged tree. Verified 2026-09-07: staging a file inside a worktree leaves the main checkout's git diff --cached empty, and the two git write-tree hashes differ. That removes the whole hazard behind --staged, whose only footgun in a shared checkout is that the index it writes out is everyone's. From a worktree the index holds nothing but your work, so --staged is simply the right tool for "what will my next commit contain" again.
What a worktree does not fix, so read the rest of this file anyway: the shared index files — CONTRIBUTING.md, ROADMAP.md, mkdocs_hooks.py, a chapter README.md — still collide. But from a worktree they collide as a merge conflict git shows you, rather than as an edit made against a stale copy that silently drops someone's committed rows. Land the work with a rebase onto origin/master, and remove the worktree when you are done.
Before you commit¶
python3 tools/check_all.py # every gate, in CI's order
python3 tools/check_all.py --staged # the tree your next commit would make
python3 tools/check_all.py --committed # the same, against what CI will check out
That runs the eight commands CI runs — run_examples.py --check, check_link_style.py, check_decomposed_literals.py, check_katas.py, check_chapter_status.py, check_fragments.py, check_nav_chain.py (the middle five with their --selftest first), and uv run --group docs mkdocs build --strict — and you can still run any of them alone. --strict fails on a broken internal link, which is the failure most likely to reach the published site unnoticed. The examples job also runs on macOS in CI; a shell example that passes here and fails there is a BSD/GNU difference, not a flake.
A gate that did not run is not a gate that passed. The runner skips a gate whose tool is not on PATH — without uv, that is the last three — and says so where you cannot miss it: the summary line counts what ran (10 of 13 gates ran and passed; 3 skipped: nav chain selftest, nav chain, mkdocs --strict.), says all 13 gates only when all thirteen did, and a skip exits 1, as a failure does. --allow-skip accepts a partial run and exits 0, but the count stays on the summary line. Until 2026-09-10 a skip was counted nowhere: with uv taken off PATH, the runner at b4cd227 printed three SKIP lines, then all 11 gates pass., and exited 0 after running eight. That is the false green the runner exists to prevent, arriving through the runner itself — CI was never affected, because the workflows run each gate bare.
Two reasons to use the runner rather than the eight commands.
The first is that these gates print a line per example, so the natural way to run one by hand is to pipe it — and a pipeline's exit status is the last command's, so run_examples.py --check | tail -1 reports success no matter what the gate said. A red gate then scrolls past under a green-looking summary line. set -o pipefail fixes it — in bash and in zsh — if you remember it every time. What does not fix it is the idiom most people reach for next: ${PIPESTATUS[0]} is bash's spelling, and under zsh it expands to the empty string rather than erroring, so echo "exit=${PIPESTATUS[0]}" prints exit= and reads like a stumble instead of a wrong answer (zsh's own array is lowercase and 1-indexed, ${pipestatus[1]}). That is the same failure one level down, so prefer set -o pipefail or a plain redirect over any array lookup — and check_all.py does not pipe at all: it keeps each status and prints a failing gate's output only when there is one.
The second is --committed, which extracts git archive HEAD into a temporary directory and runs the gates there. That is what CI checks out, and it differs from your working directory in both directions. An untracked file makes your tree red where CI is green — somebody else's half-built lesson in a shared checkout does this constantly. And an untracked file that a committed page links to makes CI red where your tree is green, because --strict resolves the link against a tree where the target exists. Only the second one reddens the build for everybody, and only --committed can see it coming.
And --staged, which is the one to run in the minute before you commit. --committed archives HEAD, so it cannot see the index at all — it is green and says nothing about the commit you are about to make. --staged writes the index out with git write-tree (which reads the index and moves no ref, so it is safe while others are working) and gates that tree. In a checkout several sessions share, that gap is where the damage happens: git add on a shared file takes a colleague's in-flight lines with it, and the resulting tree can fail a gate that both your working directory and HEAD pass. Observed twice on 2026-09-07 — once against the author of the gate that caught it.
And --mine, which is the only mode that isolates your uncommitted work while somebody else is mid-edit:
It extracts HEAD and copies in only the paths you name, so what it gates is HEAD plus your work and nothing else. Each of the other three is green or red for reasons that need not be yours: the bare working-tree run reads colleagues' unstaged edits, --staged writes out the shared index so their git add enters your verdict, and --committed cannot see uncommitted work at all. You must name the paths, and that is deliberate — inferring them from git status reproduces the very bug the mode exists to dodge, since on 2026-09-07 a half-applied rename left one session's tree showing six deletions and an addition that belonged to another session. A named path that is gone from your tree is treated as a deletion and removed, because deleting a file is work too. A named path that is in neither your tree nor HEAD is refused, before any gate runs, with exit 2 and the path named. Until 2026-09-10 it was noted SKIPPED and the run went on, so --mine 06_Terminal/my_lesonn, for a lesson called my_lesson, gated HEAD plus nothing of yours and exited 0 whenever HEAD was green — a pass that never looked at your work. Refusing costs nothing, because such a path can change nothing in the tree being gated: the only ones that are not mistakes are a file created and deleted without ever being committed and one whose deletion is already committed, and naming either is a no-op. Paths are relative to the repository root, not to the directory you are in — or absolute: an absolute path inside the repository is converted to one from its root, so the tab-completed --mine "$PWD/06_Terminal/my_lesson" gates 06_Terminal/my_lesson. A path that does not land inside the repository is refused the same way, exit 2, before anything is extracted: one elsewhere on disk, one that climbs out with .., and the root itself, which would claim your whole working tree, colleagues' edits included — that is the bare run. Until 2026-09-10 an absolute path was used as it stood — pathlib's / returns an absolute right-hand side unchanged — so the path --mine read your directory from and the path it copied it to were one and the same, and mirroring, which clears the destination first, deleted your real directory, uncommitted work and all, then crashed with nothing left to copy. A .. path read from beside the repository and wrote beside the temporary tree.
The division of labour between the four: --mine before you commit, --committed after you commit, the bare run when you are the only session working — and --staged only in the narrow case below.
--staged's condition is not about your working tree, and an earlier version of this paragraph got that wrong. The hazard is a colleague's git add, so your own tree's cleanliness is beside the point. Measured in a scratch repo: with a peer's peer.txt staged and my mine.txt dirty but unstaged, git write-tree produced a tree containing their staged content and not my dirty file. The testable condition is therefore one command about the index, not a feeling about your directory:
And even then it describes a commit you are probably not making. git commit -- <paths> builds a temporary index, so a pathspec commit — the house habit here, precisely because the checkout is shared — ignores whatever else is staged. Same scratch repo: the pathspec commit contained my file and left the peer's staged work uncommitted, while --staged had just gated a tree containing it. So --staged answers exactly one question, and it is narrower than "it sees the index": you are about to run a bare git commit or git commit -a, and this is what that will contain. Any other time, --mine.
python3 tools/check_all.py --selftest proves the runner still reports a failure, in the same spirit as check_decomposed_literals.py --selftest. Then it proves a gate that never ran is not reported as a pass: it swaps in a gate whose executable does not exist and asserts that the run exits non-zero, counts 1 of 2 gates ran, names the skipped gate, and never prints all 2 gates — beside a complete two-gate run as the control, which must print it, so that the phrase's absence is a finding rather than a typo in the test. And then it proves --mine assembles the right tree, by building a scratch repo in which a colleague has edited and staged a file you did not name, and asserting that their bytes do not reach the gated tree. That part is the one that distinguishes --mine from the modes that already existed, so it is the one that had to be tested. Last, it runs --mine itself in the same scratch repo, with the gates swapped for one stand-in that copies the tree it is handed, because the refusal is a decision no tree can show: a run that should be refused assembles a perfectly good one. Naming real work, deletions included, must run the stand-in and exit 0; the same paths plus one that exists nowhere must exit 2 with the stand-in never run. A lesson holding an untracked draft, named by an absolute path through a symlink, must be gated — the stand-in must see the draft — and must still hold it afterwards. The real work plus three paths that do not land inside the repository must be refused, naming exactly those three, with the directory beside the repository untouched. Those runs make their temporary trees inside the scratch directory, so were the deletion ever to come back, the selftest would delete scratch rather than anything of yours.
Before you push¶
git push origin master resolves the branch at PUSH time, not at commit time. In a checkout several sessions commit into, that is a time-of-check/time-of-use race: a colleague committing in the window between your git commit and your git push moves the local master your push is about to read, so you send their commit along with yours — a commit you have not read and whose gates you have not run. HEAD:master has exactly the same problem, and so does a guard, because a guard checks a state the push then re-reads.
Observed 2026-09-07, and the guard is the instructive part. A session committed d511962, guarded the push with [ "$(git rev-parse origin/master)" = "$(git rev-parse HEAD~1)" ] — which passed, correctly, for the commit it had just made — and then ran git push origin HEAD:master. Another session committed 186de1f onto the shared master eight seconds later — after the guard had run and before the push did. Both went. The direction matters, because it is the only one the story can have: 186de1f's parent is d511962, and had it landed first the guard would have compared origin/master against it and failed. The window a guard cannot cover is the one after it. The push output read 73b3ceb..186de1f, naming a SHA that session had never seen, and the two commits then had to be disentangled across three sessions' messages because the shipped work looked like the pusher's.
Push the literal commit you verified:
That refspec names one commit rather than a branch to be re-read, so nothing made after it can ride along. Verified: pushing an older SHA is rejected as non-fast-forward rather than quietly sending whatever master now points at, which is the proof the refspec is not resolved a second time.
And it is self-verifying, which is the other half of the -q rule. The literal form echoes your own SHA back on the left of the arrow, where the branch form can only ever say master:
git push origin "$sha:master" b3bf422..a7a9ab3 a7a9ab39e589355f85ee2d07a68af3d0c1e0a036 -> master
git push origin master b3bf422..a7a9ab3 master -> master
So the SHA you typed is the SHA it prints, and a mismatch needs no comparison by hand. master -> master cannot tell you what it sent under any circumstances, which is why -q costs more with the branch form than with the refspec.
Getting it wrong fails safe. "Push a specific SHA" sounds like the dangerous option and is the opposite: if a colleague landed first, the literal push is rejected as non-fast-forward rather than rewinding their work. The failure mode is a refusal, not a loss.
A rejection is normal here, so do the three steps as one command. With several sessions committing, origin/master moves between your git fetch and your git push often enough that a rejection is routine rather than exceptional — observed twice in a row on 2026-09-07, once in the seconds between rebasing and pushing. Rebase and retry; the window is what you are shrinking:
git fetch --quiet && git rebase --quiet origin/master && git push origin "$(git rev-parse HEAD):master"
And confirm it landed before you clean anything up. The mistake that produced this paragraph: a worktree removed and its branch deleted after a rejection, on the assumption the push had gone — which orphaned the commit until it was recovered by SHA from the object database. Removing a worktree deletes the only checkout of that work, and git branch -D deletes the only ref to it. Confirm first:
git fetch --quiet
git merge-base --is-ancestor "$sha" origin/master; echo "landed=$?" # 0 = yes, 1 = no
Fetch first, and compare against origin/master, not against git ls-remote. That check has three exit codes and only two of them are answers: 0 landed, 1 not landed, and 128 could not tell you — fatal: Not a valid commit name — which is what you get when the remote tip is an object you have not fetched. In this repo that is the likely case, because a colleague has usually pushed since. A naive if ! git merge-base … reads 128 as "not landed" — a check that errored and a check that answered no look identical, which is finding 15's shape (grep -l PAT $(cat filelist) past ARG_MAX: "no matches and nothing that reads as an error") in a different tool. Fetching first makes origin/master a local ref, and 128 cannot arise. The general form is worth carrying: when a check can fail to run, test its status against the specific code that means no, never against empty output or a bare if !.
What a refspec cannot do is separate an ANCESTOR, and the line above is deliberately narrow — nothing made after it can ride along, not nothing at all. If a colleague committed before you and you committed on top, their commit is in your history by definition and no refspec on earth excludes it. That case is not hypothetical and not rare: it was live while this very paragraph was being written, with a7a9ab3 sitting committed-but-unpushed on the shared master, so any commit made on top of it could only ship by shipping it too. It then resolved on its own, because that commit's author pushed first — which is the ordinary outcome and the reason the ancestor case is so easy to never notice. Had they been a minute slower it would have travelled under someone else's push, exactly as 186de1f did. So the closing line below is not a backstop for this section, it is the only check that covers the ancestor case at all — read what you are about to carry, and gate what actually landed.
Reconstructing one of these afterwards: use parentage, not the clock. git rev-parse <sha>^ is the only authoritative answer to which of two commits landed first — 186de1f's parent being d511962 is what settled the case above. If you do reach for a timestamp, ask for the committer date (%cd): git log --date=… prints the author date (%ad) by default, and that is the one that moves under --amend and rebase. Measured here on 2026-09-07: across 60 parent/child pairs there were zero committer-date inversions and exactly one author-date inversion — a rebased commit showing author=09:29:52 against committer=09:31:50. So the clocks in this repo are not scrambled by concurrency, and a reader who goes looking for that will not find it; the single thing that misleads is %ad after history editing.
And do not use git push -q. The ref-update range it suppresses — 73b3ceb..186de1f — is the only thing that tells you a SHA you did not create just went out under your name. Read it, and if it does not start at the commit you expected, work out what you shipped before doing anything else.
And ahead 1 is not evidence that anything is unpushed. git status -sb compares your branch against refs/remotes/origin/master, which is a cache updated only by git fetch — so in a checkout where somebody else may have pushed, "ahead" often means "you have not fetched". Ask the remote instead: git ls-remote origin master contacts it and caches nothing, and git merge-base --is-ancestor <sha> $(git ls-remote origin master | cut -f1) answers "did my commit really land". Observed 2026-09-07, as a false alarm between two sessions: one warned the other that a commit was sitting unpushed and would ride out under the next person's push, and it had been on origin for several minutes. Same shape as the rest of this section — a number that looked authoritative because the tooling handed it to you.
And "I fetched" is not evidence that you fetched. The other half of the same false alarm: the session that raised it had run git fetch immediately beforehand — as git fetch --quiet 2>/dev/null, with the error channel discarded and the exit status never read. A fetch that fails exits 128 and, spelled that way, prints nothing at all, so a stale tracking ref and a fresh one are indistinguishable in the transcript. Never discard git fetch's stderr, or check $? if you do. This is the same rule the top of check_all.py argues for gates — a command that fails silently is worse than one that fails loudly, and redirecting its output is how you build the first out of the second.
Afterwards, check_all.py --committed gates whatever actually landed rather than what you meant to send, which is the backstop for all of this.