Axiom 0.6.0
0.6.0 — 2026-08-31
-
KNOWN ISSUE: module resolution matches case-insensitively on macOS, so
a project shadows a standard-library module it did not mean to.
Shadowing itself is intended and documented — the resolution ladder
puts the entry file's own directory first, and a project is meant to
be able to supply its ownStr. What is not intended is that on a
case-insensitive filesystem the FILESYSTEM decides the match: a file
namedstr.axorSTR.axsatisfies a lookup forStr, so the same
tree resolves differently on macOS and Linux. Measured 2026-08-31:
withstr.axbeside the entry file,(import IO)fails with
AX3001 undefined variable strLenon macOS and builds on a
case-sensitive filesystem.The second half is the diagnostic. Nothing in that error mentions
thatStrresolved to a local file, so the message a user gets for a
shadowed module is an undefined name from inside the standard
library — accurate and unhelpful. A resolution that silently picks a
different file than the reader expects should say which file it
picked.Not fixed in 0.6.0, deliberately: it is pre-existing, and changing
module resolution semantics under release pressure is how a worse bug
ships. Recorded here so someone who hits it recognises it rather than
debugging their own code. -
The seed lineage is verified end to end on every push, and the thing
that makes that cheap cannot be used to hide a broken link.
scripts/check-seed-lineage.sh --fullreplays every row of
bootstrap/CHAINfrom the Rust anchorbb730db, and its cost is
linear in the number of reseeds this project has ever done - 10m40s
over fourteen rows, measured on darwin-aarch64 - so it ran nightly and
a default run replayed the newest row and said nothing at all about
the thirteen before it.bootstrap/CHAIN.checkpointis the record
that run was missing: it names a PREFIX of the table and the sha256 of
exactly that prefix - the rows and orphan lines verbatim, every short
hash resolved to a full commit, the git object id of every seed those
commits carry, the sha256 of every walk list and patch file they name,
and the anchor - and the gate RECOMPUTES that digest from
bootstrap/CHAINon every run before it skips a single row. 0.7s to
recompute; a default run costs 38s against the 36s it cost when it
checked nothing about the prefix. A covered row that moved by one byte
digests differently, the checkpoint is void, and the gate replays
every row from the anchor and goes red: editing an old row cannot
shrink the work, only enlarge it. Five probes assert exactly that on
every invocation, over a synthetic checkpoint the gate builds from the
table in front of it - the passing direction included, so a verifier
that refuses everything cannot satisfy the four refusals. The
checkpoint is advanced only byAXIOM_BLESS=1 scripts/check-seed-lineage.sh --full, over rows that same process
replayed from the anchor, and never as a side effect of a passing run;
it never covers the newest row, so the link a push adds is replayed on
that push. It is a record, not a signature - whoever can edit a row can
recompute the digest, and what the file buys is that they must do it in
the same reviewable diff while the nightly--fullre-derives
everything frombb730dbregardless.AXIOM_LINEAGE_FULL=1is
--fullfor a caller that cannot pass an argument. -
Traits and
implare gone from the language. An interface is a
CAPABILITY RECORD now — a parameterised struct holding the functions,
bound withfnand passed as a value — so dispatch is application and
there is no resolution rule to learn.traitandimplare reserved
and drawAX2004with the migration in the message.stdlib/Show.ax's
trait and its fourimplblocks are deleted:showResolvein the
checker rendersString,Bool,Int,Float,Charand every
data/structfrom the argument's static type, and always did.
deriveEq/deriveShoware unaffected — they generate plain functions
and always did. What is actually lost, stated rather than left to
be discovered: a user could override the built-in rendering for their
own type with(impl (Show Color) ...), and there is no override hook
now;deriveShowstill generatesshowColorto call deliberately.
compat/BREAKINGdeclares it. Nineteen fixtures went with the
construct, but THREE were pinning properties that outlive it and were
rewritten rather than deleted:010-trait-scopeand
020-trait-duplicateasked whether a global value-namespace occupant
captures a spelling aletor parameter should shadow, and now ask it
of aneffectOPERATION, which is the occupant a program can still
declare (isBuiltinName,isMacroNameandisEffectNameare the
others the resolver asks about).373-shared-default-bindercould not
be rewritten, and why is the substantive result — see the next entry. -
A guard whose only reachable path was traits, and the check that
asserted it becoming a check that cannot fail.
stampPatBinderTykeeps a three-state stamp whose third state is "two
checks disagreed", because a binder node could be checked twice at two
types and last-write-wins would hand codegen anIntfor a binder that
is really aString— a release of a block the binder still points
into. The only path was a trait DEFAULT body, whichcheckImplComplete
synthesized into everyimplwithout copying the nodes. Measured
2026-08-31 by building the last-write-wins compiler and diffing emitted
IR: 278 fixtures, everystdlib/module andself_host/main.axitself
are BYTE-IDENTICAL. Soscripts/check-fallible-reclaim.sh's second
half had quietly become a check that cannot fail — this repository's
most-refused defect. It is INVERTED rather than deleted: it now proves
nothing in the tree reaches the arm, with the whole compiler as the
subject, and goes red the day a future construct re-checks a body per
instantiation. The arm stays, because it guards a class of mistake
rather than one construct, and it now has a live check on it. -
Restriction profiles had zero production use, and the gate only
worked because of it.no-io,no-alloc,no-cast,no-recursion
andno-foreignshipped in Ada round 1, are enforced, and appeared
only intests/diagnostics/. Tagging one function turned
scripts/check-restrictions.shred twice for reasons unrelated to that
function: section 1 stripped#restrict=from only the tagged side of
its diff, and section 5's manifest credited a fixture with any tag it
could see through an import. One root cause — both assumed no
#restrict=existed outside the gate's own fixtures, true only while
adoption was zero. Both fixed, then 239 tags across the base layer
everything else calls:stdlib/{Vec,Mem,Str,Utf8,Fmt}.axand
self_host/{core,style,lexer,diag}.ax, chosen becausesymbols --diagnostic-format=aifound 1393 functions tree-wide satisfying both
no-ioandno-allocand these nine files are the ones whose import
graph provably reaches noextern(Ffi.axandrustbind.axare the
only files in the tree with one). The five#effects-incomplete
declarations —vecSortBy,vecSiftDownByand three inHttp.ax, all
indirect-call sites — are deliberately untagged, because a claim there
drawsAX3051rather than passing, which is the effect walk correctly
refusing to vouch for a call it cannot follow. ABLATED, and the number
is the point: breaking three functions produced 108AX3049s, each
naming its own witness path, so the transitive fixpoint is doing the
work rather than a single accepted claim. 173 programs still emit
byte-identical IR, diagnostics and AXSYM with every one restricted.
tests/agent/restrictions.allow49 → 288 rows. -
restrict(no-wrap), and it is LEXICAL where three of its siblings
are transitive.+,-and*lower to plainadd/sub/mul
with nonsw, so overflow wraps silently; claimingno-wrapand
writing a raw operator is an act this body performs rather than a fact
the effect row carries, which is the same reasonno-castis lexical.
tests/diagnostics/383-restrict-no-wrap.ax;explaindocuments the
distinction andscripts/check-tools-selfhost.shholds that text to
the compiler's own. -
Removing a keyword broke the direction
reseed.sh's rule does not
mention.scripts/check-seed-provenance.shregenerates the seed from
the commit that last touched the six.llfiles using the CURRENT
compiler; that commit's tree still saidtrait, the current compiler
answersAX2004, and the gate went red — whilecheck-bootstrap
stayed green throughout, because the old seed understands a SUPERSET of
the language and could still build the new tree. The seed is a TWO-WAY
compatibility boundary andreseed.shdocuments one direction ("the
seed moves when it can no longer compileself_host/"); the other is
that the seed's recorded source must stay buildable BY the tree. Budget
a reseed with any construct removal.bootstrap/CHAINgains a
stage2row and notbridge-needed, socheck-seed-lineage.shstill
replays back to the Rust compiler atbb730db. Verified before
committing: seeds matchSHA256SUMS, the seed built darwin-aarch64
with no Rust, compiledself_host/into stage1, and stage2 and stage3
are byte-identical. -
gate_initresolved the compiler as${AXIOM:-.axiom-bin/axiom}and
never mentionedAXIOM_AXC. Twelve gates that call it without
gate_build_axcsilently measured the installed binary while a caller
believed it was testing theirs. It now resolvesAXIOM→AXIOM_AXC→
bootstrap and prints which it took and why, every run; and it runs the
compiler once before handing control to the caller's loop, so a binary
that cannot exec produces a diagnosis instead of N empty failures —
exit 137 names the macOS signature-cache mechanism and the rm-then-cp
fix. The sweep inscripts/check-gate-lib.shproving no site states a
stale gate count loopedseq 15 38while its own table went to 46, and
that was not hypothetical:scripts/build-shared-axc.shdisagreed with
itself, stating a superseded count in one breath and the correct
forty-six in four others, live in the tree and unseen. The bound is
derived from the table now. 187 checks → 236. (The stale figure is
described rather than quoted here on purpose: this sweep reads the
## Unreleasedsection as a claim about the tree, correctly, so a
changelog entry that quotes a wrong count verbatim re-introduces the
defect it is reporting.) -
Dogfooding the two features above: a one-constructor
databecomes
the struct it always was, and four sentinels answerOption.
stdlib/Http.ax'sHttpHandlerwas(data HttpHandler (HttpFn (-> Int HttpReq Int))), matched apart inhttpCallto reach its one
field - exactly the shape parameterised structs exist to replace,
even though this one needed no type parameter. It is now(struct HttpHandler (run : (-> Int HttpReq Int))), built the same way
((HttpHandler (lambda (fd r) ...))) and read with((h.run) fd r), nomatchrequired.stdlib/Path.ax's
pathLastSlash/pathExtIndexandstdlib/Agent/Tags.ax's
axsymHexVal/axsymPctAtanswered a raw-1for "not found";
docs/error-model.mdERR-REC-3 says absence wantsOption, and
both modules - named incompat/SENTINELSas exceptions to the
file's own direction rule - now read zero. All four had zero
external callers (checked by grep over the whole tree before
porting), so each port stayed inside its own file;pathExtIndex
keeps going straight to the private-1-returning helper rather
than through the newpathLastSlash, because it needs the sentinel
back in arithmetic, not a value to branch on. Five lines in
compat/BREAKINGdeclare the surface change;#effects=gains
nothing, matching theOption Intfunctions the census already left
alone (strFind).tests/stdlib/055-filesystem.ax,
tests/stdlib/380-agent-tags.axand
tests/stdlib/432-http-router.axpin the observable behaviour
unchanged. -
Structs take type parameters, and that is what an interface is now.
(struct ShowOf (a) (render : (-> a String)))- the parenthesised
spellingdataalready uses, one convention rather than two. Before
this the form wasAX2001, so a capability record had to be adata
type destructured bymatch, because only a struct has named fields
and.fieldaccess.(fn (showInt) (ShowOf fmtInt))is the
instance,((c.render) 7)is the call, and nothing dispatches at run
time.
The first group is ambiguous in a struct and not in adata, which
is the whole difficulty. A data declaration's other groups are
CONSTRUCTORS and start uppercase, so(a)can only be parameters. A
struct's other groups are FIELDS and start lowercase too. The rule is
that a parameter list is lowercase names and nothing else:(a b)
yes,(start : Int)no - a colon makes it a field - and
(msg String)no, becauseStringis uppercase, which keeps
tests/diagnostics/388's threeAX3056refusals intact. Both halves
were found by the tree refusing to build, not by inspection: handing
the group tocollectTyParamsunguarded swallowedSpan's first
field and the SEED stopped compilingself_host/main.ax; testing
only for the colon then swallowed(msg String)and oneAX3056
vanished.
Two more caught the same way. Writing the parameter vector onto
everyD_STRUCTnode'styslot gave the macro expander a handle it
walked as a type node -381-macro-type-templatesand
396-macro-struct-field-typesdied of SIGSEGV. An unparameterised
struct now leaves the slot at 0, andcheckStructConAtguards
(== tvs 0)beforevecLen, which dereferences null on 0.
The grammar agreed with the text and disagreed with the compiler.
tree-sitterparsed(a)as a typelessfield_declarationwith
zeroERRORnodes - accepted, and wrong. A dynamic precedence on
type_parameterssettles it, and only where it is genuinely
ambiguous, since(x : Int)cannot match that rule at all.
And the formatter broke its own input, printing a single variable
bare: right fordata, which accepts both spellings, fatal for
struct, which accepts one.axiom fmtwas producing files the
compiler could no longer read.fpTyvarsnow takes the choice from
its caller.
tests/selfhost/901-parameterised-struct.axpins it at exit 12: two
parameters staying independent, and a capability answering through
its field. -
sysWriteAllFdstopped truncating output silently, and a claim about
why theResultmigration is hard, CORRECTED below.
Whenwritereturned exactly 0 - a legal answer, and the one case
the loop cannot retry - this answereddone: a short, NON-NEGATIVE
count indistinguishable from the complete one.stdlib/Sys.ax's own
comment calls treating a short write as success "the classic way to
truncate output". It answers-errShortWritenow, so the callers
that already ask< 0- all of them - see it.
It was ported to(Result Int Error)first, and that is the
interesting part.ERR-ADOPT-1records(Some v)at 7.4x a-1
and treats cost as the reasonSys.axis hard. Measured here,
2,000,000 calls at--opt 2: 1.399 / 1.065 / 1.054 s sentinel
against 1.298 / 1.092 / 1.093 s Result - the same number. Awrite
is ~500 ns and the constructor ~9, so on a wrapper that reaches the
kernel the channel is free. The 7.4x figure is real and was taken
with no syscall in the loop.
The port DID widen the effect row fromIOtoIO, Alloc, Mutand
ten gates went red -300-effect-handlerscould no longer
handlewhat it declares, and AXTAG claims began readingbody performs Alloc, IO, Mutwhere they had saidIO. The cause written
here first was "the row comes from the constructor". That is
wrong, and the correction matters more than the port: measured, a
Result-returning function whose error message is a LITERAL has no
effect row at all, and the same function with astrConcated message
hasAlloc,Mut.Ok/Err/mkErrorare effect-free. The tax came
fromsysResult, which builds"op: errno N"eagerly with two
strConcats and anfmtInton a path most callers never read. The
narrowing test that "proved" otherwise replaced one message with a
literal and leftsysResultuntouched.
So theResultmigration is not blocked by the effect system.
It is blocked by eager message construction, which is one line.
Also corrected:Sysdoes NOT sit belowErrin the dependency
order, whichERR-ADOPT-1gives as the reason its slice is hard.
The order isMem -> Vec -> Str -> Err -> Sys;Sys.axalready
importsErrand already usessysResult. Andself_host/reaches
only 4 of the 27 sentinel-answering functions (20 sites), so 23 of
them can be ported without touching the compiler at all. -
freebsd-x86_64andwindows-x86_64are supported targets. Both
legs had been green on 13 of the previous 15 runs with no failures,
and README's rule is thatcontinue-on-errorcomes off after the
evidence — never in the commit that adds the job, which cannot have
seen anything. Both lines are off; both legs can now fail the
workflow, which is the whole content of the word.
The two legs do not cover the same amount, and the documents say
so rather than letting one word mean two things. FreeBSD boots a
real 14.4 kernel in a VM, bootstraps from the committed seed and runs
the whole standard-library corpus plus five gates, including
check-net.shopening a real listener on::1. Windows runs one
program —hello.exe, executed onwindows-latestagainst
tests/stdlib/010-hello.out, imports held to an allowlist with a
leaky probe proving the allowlist refuses. Both satisfy the rule;
only one would catch a Windows-only miscompile in a module hello
does not touch. Widening it means running the corpus thecrossjob
already assembles.
Supported as a TARGET is not supported as a HOST: the compiler
does not run on Windows, there is no Windows seed, andinstall.sh
refuses a Windows host outright.
freebsd-x86_64is supported and UNSHIPPED, soinstall.shnow
gives it the build-from-source paragraphlinux-x86_64gets rather
than the not-supported one — whilefreebsd-aarch64, same seed and
same syscall table but no leg, keeps the latter. That split inside
one operating system is the clearest statement of the two axes the
previous release note introduced.
Three defects found on the way, two of them in checks:
SECURITY.md's exclusion was keyed on an OS (**Windows.** Not a supported target) andcheck-doc-drift.shrequires at least one
such bullet — but every OS in the list now has a supported target, so
the premise had become unsatisfiable. Both are keyed on the TARGET
now, which is what the rule was always about.
check-release-targets.shmatched CI legs withgrep "name: $t",
which finds a matrix entry but notname: Tests (freebsd-x86_64)—
it would have reported both newly-promoted targets as supported with
no leg.
And nothing anywhere assertedcontinue-on-error: the single
line that decides whether a leg can fail, whose removal every
document defines promotion as, was checked by no gate — a leg could
have been made advisory again to turn a red build green and every
check would have stayed quiet. There is now an arm for it, and it was
vacuous on its first write:awk -v pat="Tests \($t\)"has its
escapes processed by-vbefore the regex sees them, so\(became
a group and the pattern matched nothing. Caught by ablating it —
injecting the line back and watching the check stay green — which is
the only reason it is a check today. -
The evidence words travel by DEPTH, and two documented claims were
wrong in opposite directions. Both were read off the emitter's
shape instead of measured, and both were corrected by probes that
should have been written first.
The effect-operation path was never a hole. It passes the constant
0, which is what the claim was read from — but a handler's
parameter is bound to the OPERATION's declared type andAX3017
refuses a type variable there, so it is always ground and the retain
is unconditional. Measured:call @Vec$vecPush(i64 %.t2, i64 %m, i64 1), and a handler parking a struct field answers correctly. The
word that path passes is one the handler never reads.
The outer parameter of a curried lambda was worse than stated. It
was called a leak; it was a live use-after-free.(lambda (a b) ..)is(lambda (a) (lambda (b) ..))by the parser, so a store of
aruns inside the inner lambda witharead out of the closure
record, and the inner lambda's own word is aboutb. Nothing carried
the word that classifiesa.
SocurLamVaris a stack rather than a name,evClassOfanswers
EV_LAMARG - dfor a parameterdlambdas out,collectCapNames
takes the enclosing lambdas' words into the nested record the way it
already took the enclosing function's, andbindCapsshifts each one
level as it binds — every lambda's own argument stays depth 0 and no
witness is renumbered after the fact.
New fixturetests/stdlib/461-curried-closure-arg.axpins four
depths across two- and three-parameter lambdas. It is a separate file
for an arithmetic reason worth recording:460's exit-status bitmask
uses all eight bits a process exit code has, so a ninth term
there is511 & 0xFF— 255, the same answer it gives today, while
asserting one more thing than it can say. Built by the compiler one
commit back this file does not answer wrongly, it exits 139: four
uncounted parks in one process recycle blocks into each other until a
header read lands outside the heap.
What remains is unmeasured rather than known safe — the surplus
arguments of acastspine, and the over-applied path. That is
deliberately the same sentence shape that was wrong twice above, now
said about what has no probe instead of about what is fine. -
The seventh closure shape closes, and the reason it was left open
was wrong. A factory whose declared result arrow carries a type
variable —(:: mk (-> Int (-> a Int)))— emitted a correct chain
and still under-retained, becauseaappears only inmk's result,
so nothing atmk's call site witnesses it and the caller passes
0. It was left open on the ground that a SOURCE variable cannot be
matched by name the way a minted placeholder can, since "amay
denote other values in the same body".
That conflates values with types.adoes denote several values —
the parameter, a capture, a temporary — but the evidence word is a
fact about a TYPE, and a type variable denotes one type throughout
its scope: if the application hands over aStringfora, every
ain that body is aString. Nothing can shadow it, because type
variables come from the signature being checked and expressions
cannot introduce their own. SocheckLamAgainstnames a
type-variable parameter andevClassOfanswers itEV_LAMARG, the
application's word beating the enclosing function's — which is the
one that arrives as0.
All seven measured shapes now readawhere they readz. Two
probes hold the reasoning rather than the conclusion: a lambda whose
parameter and a capture shareaparks both and answers correctly,
and the same factory applied atIntstores and reads back41—
evidence0, no retain, no reference count on an integer. The
remaining unclassified applications are down to two, both leaking
rather than freeing early: the effect-operation path and the outer
parameter of a curried lambda. -
The gate battery can be run on Linux from a Mac, before CI sees
it —scripts/run-gates-linux.sh, the same battery and the same
scripts inside a container. This is the feedback-loop repair the two
entries below are symptoms of: both were gates written and validated
on darwin, where the local battery runs, going green on the machine
that wrote them and red on a leg that had never seen them. Neither
was a defect in the target.
It copies the tree into the container and mounts the repo
read-only, which is the one decision worth reading:gate_init
bootstraps a compiler into$repo_root/.axiom-binwhen it finds
none, so a writable bind mount would leave a Linux binary in the
checkout and the next darwin gate to reuse it would fail for reasons
unrelated to the change under test. Defaults to the host's native
architecture —linux-aarch64on Apple Silicon, at full speed;
--arch amd64runslinux-x86_64emulated and says so, that being
the leg both defects came from. A missing container runtime is an
error with install instructions, not a silent skip. Not a gate: it
asserts nothing about the tree andrun-gates.shdoes not glob it.
What is verified and what is not: argument handling, every exit
path, the runtime-absent message, the image-tag hash, and the tar
copy exercised against the real repo (.gitand.axiom-bin
excluded, exec bits preserved). The in-container run itself is
unexercised — no container runtime is installed on the machine
this was written on, and the header does not claim otherwise. -
check-steady-state.shfailed a run whose memory went DOWN. The
plateau arm compared|b - a|against a 256 KiB band, so a fall past
the band failed exactly as growth did. It fired on trunk, on the
linux-x86_64 leg:aggregate/owning moved 1460 -> 1196 KiB over ten times the work - that is not a plateau— 264 KiB apart, eight past
the band, in the shrinking direction.
The number being compared isru_maxrss, a high-water mark taken
from two separate process lifetimes. It cannot fall because the
program held less live data at 100× the work; only because the
runtime, the loader or the allocator touched fewer pages that time. A
leak has no way to express itself as a smaller peak, so the symmetric
band was testing a property the gate's own subject does not imply.
The band is directional now: growth past it fails, a fall past it is
reported and not hidden — a drop that large is worth a reader's
eye even though it is not this gate's subject. Nothing weakens.
Memory that is merely large is still caught by the 4096 KiB ceiling,
the ablated twins by the ratio arms, and both ablations recorded at
the bottom of that file grow (2,928 → 17,008 → 157,616 KiB for one),
so every FAIL line they are documented as producing is still
produced. Boundary-checked at ±256 and ±257 either way.
That is the second Linux-only gate defect in two days, after
check-thread-local.shasserted a Darwin fact as a universal one.
Neither was a defect in the target: both were gates written and
validated on darwin, where the local battery runs, going green on the
machine that wrote them and red on the leg that had never seen them. -
linux-x86_64is no longer a release artifact, and is still a
supported target. Those are two axes, not one, and until now the
project only had a word for the first.release.ymlbuilds
linux-aarch64anddarwin-aarch64; thelinux-x86_64leg was the
slowest and the most frequently re-run part of cutting a release, and
it served the platform whose users are most likely to already have a
toolchain. Nothing about testing changed:Tests (linux-x86_64)
runs the whole gate battery on every pull request, exactly as before.
scripts/install.shrefuses that host with a build-from-source
message namingbootstrap-from-seed.shrather than fetching a 404,
and it is a different message from the onedarwin-x86_64and the
two FreeBSD targets get — those are unsupported, this one is
supported and unshipped, and telling a user their platform is
unsupported when it is not would be the defect.
New gatecheck-release-targets.shholds the two lists to each
other, because they live on opposite sides of the project and neither
failure mode is loud: a target in both the matrix and the refusal
list uploads an archive the installer will not fetch, and a target in
neither gives the user a barecurl404. Seven checks — the two
lists are disjoint, together they cover every non-Windows target the
compiler accepts, every shipped target is one README calls supported,
and every supported-but-unshipped target still has a CI leg or a
README paragraph explaining why it does not (darwin-x86_64, which
is executed by no runner). Both directions were ablated: re-adding
linux-x86_64to the matrix fails on "built AND refused", and
removing its refusal arm fails on "neither built nor refused".
Forty-seven gates build the compiler under test, up from forty-five. -
Terminal primitives, and the gate that keeps them honest.
Sys
grew the floor a REPL's line editor needs and nothing above it:
sysIsatty,sysTermSave/sysTermRaw/sysTermRestore, and
sysTermSizewithsysTermRows/sysTermCols. Nineteen constants per
platform module carry them, and every one records how it was
established - measured on this host for Darwin, quoted from the
kernel's uapi headers for Linux, derived from_IOCwith the
arithmetic shown for FreeBSD.windows-x86_64answers
ttyUsesTermios0 and every call returns an error: it has no
termiosand noioctl, nothing here can executeGetConsoleMode,
and an honest unimplemented beats a plausible wrong number - an ioctl
request is a command selector plus a byte count, so a number
borrowed across platforms copies the wrong length rather than failing.
struct termiosis 72 bytes on Darwin, 36 on Linux and 44 on FreeBSD,
which is why the state buffer's size is a call and not a constant.
New gatecheck-terminal-restore.shasserts the round trip is
byte-exact on a pty it allocates itself - and asserts, in the same
breath, that raw mode changed something first, because asysTermRaw
that does nothing round-trips perfectly and would satisfy the obvious
check. Two independent witnesses: the library'smemCmp, and
tcgetattrfrom outside the process using Python's owntermios.ISIG
rather than the constant under test. Four ablations, all required.
check-install.shgained the branch this needs: on a host whose
target ships no archive it asserts the refusal instead, and says
plainly that the install path was not exercised there rather than
skipping quietly. -
check-thread-local.shasserted a Darwin fact as a universal one.
Its second arm requirednm -uto be empty for a program that
spawns no thread. That holds on Darwin and is false by construction on
Linux, where the same program imports six symbols — four weak crt
hooks (_ITM_*,__gmon_start__,__cxa_finalize) and two real ones
(__libc_start_main,abort). Both Linux CI legs went red on a
program behaving exactly as intended. The measurement that fixes it
was in the failure text: six off and six on, identical — the flag
adds nothing, which is the property the gate exists to hold and which
"zero" could not express.
The arm now asserts what the flag is actually about: a program that
spawns no thread imports no TLS runtime symbol, read through
imports_of—check-freestanding.sh's reader, which dispatches on
the object's own magic rather than the host and strips ELF's
@GLIBC_2.34versions and Mach-O's leading underscore. And the cost
is now measured as a delta: the ON binary's imports minus the OFF
binary's must be TLS symbols and nothing else — one on Darwin
(_tlv_bootstrap), none on Linux or FreeBSD, where local-exec needs
no resolver. That is a stronger claim than the one it replaces, and a
portable one. 14 checks, up from 12.
This is the second time in one release that a gate of mine assumed one
platform's shape was universal; the first was grepping only for
__tls_get_addr, which AArch64 never emits.