Phoenix / Changelog
Changelog
What has shipped, newest first, for somebody who is not reading the source.
The other records are internal and answer different questions: journal.md is the day-by-day narrative and what each stage got wrong first, postmortem.md scores the predictions, COMPLETED.md is the standing inventory of what exists, and ROADMAP.md is what does not. This page is the one that says when.
Phoenix versions by stage, tagged stage-0 … stage-7. Work after stage 7
was not planned as stages; it is dated instead. There is no release cadence and
no compatibility promise yet — the notation is still being decided, and every
entry below that changes it says so.
2026-10-11: a default reading a field where an attribute was handed down
phx warns when an otherwise clause reads $x in a pass that hands x
down, and some node it runs at has a field x. A field is read before an
attribute, so at that node the default reads the field, and what was handed
down never arrives. A clause in a rule for one node type is not warned about,
since there the field is usually the one meant. No description in the
repository is warned about.
Tests: 752 → 753.
2026-10-11: make rebuilds after an edit in the same second
make no longer skips a rebuild because a source and bin/phx share a
timestamp. The make macOS ships compares seconds, so an edit saved in
the second the binary was linked left the old binary in place. The
Makefile now compares a checksum of the sources with the one the binaries
were built from, and rebuilds when they differ. A make with nothing
changed still does nothing.
Tests: 752 → 752, unchanged.
2026-10-10: units initialised in fpc’s order, and const kept
languages/units/ runs initialisation sections in fpc’s order, a
depth-first walk from the program through each unit’s interface uses and
then its implementation’s, each unit entered once. It ran them in the order
they are written. Its divergent/ directory is empty: both of the things
it got wrong are answered.
phx builds with -Wcast-qual, and so does every compiler it writes.
The names and texts the runtime only reads are declared const, and the
casts that hid the difference are gone.
Tests: 752 → 752. The units harness holds thirteen checks, and the
written-out compilers are compiled with -Werror=cast-qual.
2026-10-10: a circular unit reference at any depth
languages/units/ refuses A -> B -> C -> A, a cycle through interface
uses of any length, where it caught only two units using each other. A
reach pass, run until what each unit reaches settles, does the closing;
the message names the unit and the unit it uses in fpc’s order, circular
unit reference between ub3 and ua3. A description over 63 units is refused,
since each unit is one bit of what another reaches.
A failed check is reported once when ... meets it. [...x] of a
failure said ... wants a list, a second line naming the description
under a correct refusal of the program.
Tests: 752 → 752. The units harness holds ten checks where it held eight, and is reported as one.
2026-10-10: make test in half the time
The suite’s harnesses run at the same time, each beside the rest of
the suite, rather than one after another. On a twelve-core machine a run
took 450 seconds before and 236 after, with the same 752 checks printing
the same lines in the same order. PHX_SERIAL=1 make test runs them one
at a time, as before.
Tests: 752 → 752, unchanged.
2026-10-10: a written-out compiler is C11
phx -o writes a compiler that is C11 to the letter. A table the
description left empty, its rewrites or embeds, say, was written as
= {}, which is C23’s, and an array of no elements, which is an
extension. Plain cc took both without a word, so every compiler written
out compiled, and none of them was C11: each failed
cc -std=c11 -pedantic-errors. An empty table now gets one zeroed row,
which nothing reads.
Tests: 750 → 752, compiling two written-out compilers under
-std=c11 -pedantic-errors.
2026-10-10: find in the library
find(text, part) answers where part first begins in text,
counting from one, or 0 when it is not there, so find(t, w) > 0 asks
whether t contains w. The descriptions here asked that by splitting
and counting, 58 times, and now ask it this way.
Tests: 750 → 750, with four claims and one refusal added to the library’s checks.
2026-10-10: calls checked when a description is read
A call to a function that does not exist, or with the wrong number of arguments, is refused when the description is read, where it used to be found only when a node reached it. A typo in a clause no test input reached could sit in a description that read cleanly.
Tests: 749 → 750.
2026-10-10: %define, named computations
A description can name a computation and call it:
%define twice(n) = $n * 2 . and then twice($x) in any clause. The body
sees only its parameters, so it means the same thing wherever it is
called; a define may call another but not itself. This adds to the
notation, and %define is a new directive.
A template’s {} count is now checked when the description is read,
as the specification always said, and not only when a node reached it.
The C description uses six defines and is about a sixth smaller, 480 KB to 402 KB; every C program in the oracle compiles to the same bytes.
Tests: 743 → 749. The new checks cover what a define is, in phx and
in a compiler it wrote, every refusal, a read inside a define seen by the
driver check, the library’s names, and the template count.
2026-10-10: repeat in the library
repeat(x, n) makes text, or a list’s elements, n times over. It
replaces a thirteen-row table the C description had pasted 65 times,
about a tenth of the file.
C pointers of thirteen or more levels now compile. The table stopped at twelve, and a thirteenth level was refused with a wrong message. Every other C program in the oracle compiles to the same bytes as before.
Tests: 743 → 743. Four claims and one refusal join the library’s
checks, and one program joins the C oracle: 355 agree with cc.
2026-10-10: if in the notation
A clause can choose with if c then a else b, which works out only
the arm it takes, so if $n = 0 then 0 else 100 div $n never divides by
zero. The condition must be a boolean, and the else is required. This
changes the notation: if, then and else now begin and end arms of an
expression, though they stay usable as field names.
Every lookup([[true, a]], c, b) in the descriptions here, 1,072 of them,
876 in the C description alone, is now an if, and every C program the
oracle runs compiles to the same bytes as before.
Tests: 743 → 743, with four claims and one refusal added to the specification’s own checks.
2026-10-06: the C compiler on hold
languages/c/ gains nothing more for now. The workspace’s C compiler
is to be written by hand in C in DevTools, and the one Phoenix generates
stops where it stands: what it compiles, it still compiles, and its
oracle stays in make test, but no construct it refuses today will be
lifted, and the ELF target for Ouroboros will not be built. Phoenix
itself is meant to run on Ouroboros one day instead, with no date.
ROADMAP section 6 says what the hold keeps.
Tests: 743 → 743, unchanged.
2026-10-05: C functions declared in a block have the block’s scope
A function declared in a block is known only to the block’s end, C11
6.2.1p4, where it was known to the end of the file: a call of it or its
name after the block is refused, as cc refuses it, until it is declared
again at file scope. And it hides a local of its name to the block’s
end, by a declarator, int f(void);, or by a typedef of its type, binop
add;, where both were refused by name.
Tests: 740 → 743. Two refusals go and five join. One program joins
the C oracle: 354 agree with cc, none diverges. Refusals: 501.
2026-10-05: C a name in its own initialiser, with a * in parentheses
int (*f)(void) = sizeof f == 8 ? g : 0; is compiled, as C11 6.2.1p7
puts f in scope from the end of its declarator, where it was refused by
name: in a local, a static local and a global, a pointer to a function
or to an array. An array whose initialiser is in braces is the one form
still refused.
Tests: 741 → 740. One refusal goes. One program joins the C oracle:
353 agree with cc, none diverges. Refusals: 498.
2026-10-05: C * in parentheses inside another’s
int (*(*p)) declares p as int (**p) does, C11 6.7.6, where it was
a syntax error: in a global, a local, a static, a member and a typedef,
around a pointer to a function and to an array, any number deep. A ) too
many or too few after one is refused with a reason saying which. One with
brackets or a function inside the inner parentheses, int (*(*p)[3]), is
still a syntax error, and so is either kind in a parameter, which does not
take a name in parentheses either.
Tests: 738 → 741. Three refusals. One program joins the C oracle: 352
agree with cc, none diverges. Refusals: 499.
2026-10-04: C variadic functions defined
A function ending in ... can read its arguments, through the four
builtins
Tests: 729 → 738. Nine refusals. One program joins the C oracle: 351
agree with cc, none diverges. Refusals: 496.
2026-10-04: C ?: between two pointers is const where either is
*(c ? p : q) = 1 with q a pointer to a const int is refused, as
C11 6.5.15p6 has it and cc refuses it, where it was accepted when q
was the second arm: the answer of ?: between two pointers to one type
is const at each level either arm is, where it had been the first
arm’s. A write under the const level is still compiled.
Tests: 727 → 729. Two refusals. One program joins the C oracle: 350
agree with cc, none diverges. Refusals: 487.
2026-10-04: C functions declared in a block, and parameters of a function’s type
int f(void); in a block declares f, and so do int x, f(void);
and binop add; with binop a typedef of a function’s type, which were a
syntax error and a refusal. Each is checked against the function’s other
declarations, as one at file scope is, and the locals around it keep
their places. static, auto or register on one is refused, as C11
6.7.1p7 has it. A parameter of a function’s type, int apply(binop f),
is a pointer to one, as 6.7.6.3p8 has it, where it was refused. A
function declared in a block where a local of its name is in scope is
refused by name, and one declared in a block is still known after it,
which cc refuses. A member of a function’s type was refused in words
saying C allows it; they now say it does not.
Tests: 713 → 727. Thirteen refusals, one more check, and one refusal
gone, since the program compiles. One program joins the C oracle: 349
agree with cc, none diverges. Refusals: 485.
2026-10-04: C () in a pointer to a function is no prototype
int (*f)() points at a function of unstated parameters, as C11
6.7.6.3p14 has it, where it was read as a pointer to one taking none: a
call through it is compiled with its arguments in registers, and it may be
given any function returning the same type whose parameters are not
char, short or _Bool and which has no ..., as 6.7.6.3p15 allows and
cc takes; given anything else it is refused, as cc refuses it. The
same holds of () in a typedef of a function’s type, a cast and a
parameter. A function declared through such a typedef, F add;, may then
be defined with parameters, and a prototype written before it still says
what is taken.
Tests: 706 → 713. Seven refusals. One program joins the C oracle: 348
agree with cc, none diverges. Refusals: 472.
2026-10-04: C names in parentheses in a declarator
int (x) = 3; declares x, as C11 6.7.6p6 has it, where it was a
syntax error: a name in any number of parentheses is the name, in a
global, a static, a local, a member and a typedef, and inside a
pointer’s parentheses, int (*(p))[2]. A name declared so is in scope in
its own initialiser, as any other is. A name in its own initialiser where
the declarator has a * in parentheses, int (*f)(void) = ...f..., is
still refused by name.
Tests: 706 → 706; one refusal reads a different parse error. One program joins the C oracle: 347 agree with cc, none
diverges. Refusals: 465.
2026-10-04: C integers cast to pointers in a global’s initialiser
int *p = (void *)0; compiles as a global or a static, where it was
refused as not worked out before the program runs; a plain 0 had been
taken. Any integer constant cast to a pointer is now an address constant,
as C11 6.6p9 has it, so char *c = (char *)0; and
int *t[2] = { (void *)0, (int *)16 }; are written into the object file
as the numbers they are, and a pointer to a function may be initialised with a 0 cast to
void * there too. A pointer cast to an integer, or given to !, &&,
|| or as the condition of ?:, is still refused where a constant is
wanted; cc takes those as an extension.
Tests: 701 → 706. Five refusals. One program joins the C oracle: 346
agree with cc, none diverges. Refusals: 465.
2026-10-04: C () in a declaration is no prototype
int f(); says nothing of f’s parameters, as C11 6.7.6.3p14 has it,
where it had been read as int f(void): a later prototype or definition
with parameters is no longer refused as a second declaration that
disagrees, and a call with arguments is compiled, each in a register, as
cc passes them. int puts(); puts("line"); works. A prototype written
before a () still says what the function takes. In a definition, ()
is still no parameters, and in a pointer to a function it is still read
as no parameters.
Tests: 696 → 701. Five refusals. One program joins the C oracle: 345
agree with cc, none diverges. Refusals: 460.
2026-10-04: C functions declared through a typedef of their type
typedef int binop(int, int); binop add, sub; declares two functions
at file scope, as C11 6.7.6.3 has it, which can be called before they are
defined and are checked against their other declarations: a different
number of parameters, a different return type, or a global of the same
name is refused. It had been refused by name. In a block, as a parameter
and as a struct member it still is.
Tests: 686 → 696. Nine refusals. One program joins the C oracle: 344
agree with cc, none diverges. Refusals: 455.
2026-10-04: C functions declared beside other names
A function may be declared in a list with other names at file scope,
int count, printf(const char *format, ...), total = 5;, in any place in
the list, and is a prototype like any other: it can be called before its
definition, and its declarations are checked against each other and
against the globals. It had been a syntax error. In a block it still is.
Tests: 682 → 686. Four refusals. One program joins the C oracle: 343
agree with cc, none diverges. Refusals: 446.
2026-10-04: C names in their own initialisers, in braces, static and global
A name may be used in its own initialiser in braces, in a static
local and in a global, as it already could in a plain local, C11
6.2.1p7: struct node x = { &x, sizeof x }, static void *p = &p;,
void *g = &g;. Each had been refused by name. Still refused by name,
which cc compiles: an array in braces, whose size its initialiser may
decide, and a declarator in parentheses.
Tests: 685 → 682. Three refusals gone to the oracle, which one
program joins: 342 agree with cc, none diverges. Refusals: 442.
2026-10-04: C enumerators from comparisons, logic and ?:, and a wrong value refused
An enumerator may be given a comparison, !, &&, || or ?: of
constants: enum { LT = 1 < 2, P = LT ? 10 : 20 }. They had been refused
by name. 1 ? 0 : 0 is now a null pointer constant, as C has it.
A fix for wrong code. An enumerator’s value with unsigned arithmetic
in it was worked out as if every number were signed, so -1 / 2u was 0,
where cc makes it 2147483647, and -2 % 3u was -2 where it is 2. Each
place where the unsigned answer differs from the exact one, a negative
operand or answer beside an unsigned number, or one past 2^32, is now
refused by name. Where they agree, 1u << 4 and A | B, nothing changes.
Tests: 677 → 685. Eight refusals. One program joins the C oracle:
341 agree with cc, none diverges. Refusals: 445.
2026-10-04: C pointers to functions checked at a call and a return
A pointer to a function is passed and returned only as its own type,
as it was already assigned and initialised: an argument or a return of
another function’s type, of a pointer to an object where a pointer to a
function goes, or of a function where a pointer to an object or a number
goes, is refused. These had been accepted, and a call through the wrong
type passed and read what that type says. cc refuses or warns on each.
(void *)0 goes into a pointer to a function, as C11 6.3.2.3p3 has
it, at an assignment, a local’s initialiser, braces, an argument and a
return. It had been refused at the first three as a pointer to something
that is not a function. It is still refused as a global’s or a static’s
initialiser, for any pointer, which is now on the roadmap.
Tests: 669 → 677. Eight refusals. One program joins the C oracle:
340 agree with cc, none diverges. Refusals: 437.
2026-10-04: C escapes past their second digit, each refused
Nothing a program can do changed. Every digit is now shown refused as
the third of a hex escape, and as the third of an octal escape that starts
with a 4 to 7, one to a line, in one program cc refuses line for line.
Until now each of those two refusals was shown by a single example, so a
digit missed would have let a string through a byte too long.
Tests: 668 → 669. One refusal: 429.
2026-10-04: C enumerators from character constants
An enumerator may be given a character constant’s value, and any
constant expression made with one: enum { A = 'a', NL = '\n', SUM = 'z' - 'a' + 1 }.
It had been refused by name. A character past 127, '\xff', is negative
here as in cc, and makes its enumeration an int.
Tests: 669 → 668. One refusal gone to the oracle, which a program joins:
339 agree with cc, none diverges. Refusals: 428.
2026-10-03: C ?: with a void * beside another pointer
A void * and a pointer to an object may be the two arms of ?:,
either way round, and the answer is a void *, as C11 6.5.15p6 has it:
len <= sizeof small ? small : malloc(len). It had been refused as two
kinds of pointer. It was the next stop in Proem’s lexer.c, which is now
accepted whole: eight of its ten files are.
Refused, which cc only warns about: a void * beside a function or a
pointer to one, and a void **, which is no void *, beside another
pointer. A program reading through such an answer, *(c ? p : v), is
refused because a void * has nothing to read, as cc refuses it.
Tests: 663 → 669. Six refusals. One program joins the C oracle: 338
agree with cc, none diverges. Refusals: 429.
2026-10-03: C functions returning a char
A function may return a char, signed char or unsigned char,
narrowed at return as a short is, so char f(int x) { return x; }
gives back the low eight bits, sign- or zero-extended as Apple’s arm64
has a callee do. It had been refused by name since functions came in. It
was the first stop in Proem’s lexer.c, which now gets 364 lines further
and stops at a ?: with a void * on one side.
Tests: 664 → 663. One refusal gone to the oracle. Two programs join
the C oracle: 337 agree with cc, none diverges. Refusals: 423. The ABI
test returns a char and an unsigned char across cc both ways.
2026-10-03: C ?: with a null pointer constant
A pointer and a null pointer constant may be the two arms of ?:,
either way round, and the answer is the pointer’s type, as C11 6.5.15p6
has it: c ? "2" : NULL and has_id ? &id : NULL, with NULL written
as Apple’s headers write it, ((void *)0), or as a plain 0, 1 - 1 or
an enumerator worth 0. It had been refused since ?: came in, and was
the first stop in two of Proem’s files, which are now accepted whole:
seven of its ten are.
Refused, which cc only warns about: a number beside a pointer that is
no null pointer constant, 1 or an int variable holding 0. A 0 cast to
any pointer type but an unqualified void * is a pointer and not a null
pointer constant, so beside a pointer to another type it is refused as
before.
Tests: 659 → 664. Six refusals, one of the old ones gone to the
oracle. Two programs join the C oracle: 335 agree with cc, none
diverges. Refusals: 424.
2026-10-02: C names in their own initialisers
A local’s name may be used in its own initialiser, as C11 6.2.1p7
has it: struct node *n = malloc(sizeof *n);, int x = sizeof(x);, and
the address of the name itself. It had been refused as undeclared since
the subset began, and it is how Proem writes every allocation. Found by
running Proem’s source through Phoenix, which no earlier change had done.
Refused by name, though cc compiles them: the same in an initialiser in
braces, in a static local or a global, and where the declarator has
parentheses.
Tests: 656 → 659. Five refusals, two of the old ones gone to the
oracle. Three programs join the C oracle: 333 agree with cc, none
diverges. Refusals: 419.
2026-10-02: C octal and hex escapes, and escapes closed
Octal and hex escapes work, the third and last part of ROADMAP
6.16, which closes it: in a
character constant, worth the char they name, so '\377' is -1 as char
is signed here, and in a string, where a \0 may now stand anywhere.
Refused, as cc refuses them: an octal or a hex escape past 255, \x
with no digit, and an escape C does not have. Refused by name, though cc
compiles it: a hex escape of more than two digits whose others are zeros,
'\x041'.
Tests: 649 → 656. Nine refusals, two of the old ones gone to the
oracle. Four programs join the C oracle: 330 agree with cc, none
diverges. Refusals: 416.
2026-10-02: C string literals side by side
Two string literals or more, side by side, are one, the second part
of ROADMAP
6.16, as C11
6.4.5p5 has it: anywhere a string is, in an initialiser, under sizeof,
and as a _Static_assert’s message. Proem writes most of its messages
that way.
Tests: 649 → 649. Four programs join the C oracle, one of them
a refusal until now: 326 agree with cc, none diverges. Refusals: 409,
one in and one out.
2026-10-02: C escapes \a \b \f \r \v \?
The six simple escapes C has and the subset did not, the first part
of ROADMAP
6.16: each is a
character constant worth its code, and each may be written in a string,
whose length counts it as one byte. Proem’s lexer tests for '\v' and
'\f', and its reader for '\r'.
Tests: 649 → 649. One program joins the C oracle: 323 agree
with cc, none diverges. Refusals: 409.
2026-10-01: C enum, and the integer types closed
Enumerations work, the fourth and last part of ROADMAP
6.15, which closes it: enum with a
tag, without one, and through a typedef, in a block or at file scope;
constants counted on from 0 or from a value written, which may be worked
out from numbers in any base, earlier constants, parentheses and the
arithmetic and bitwise operators, so Proem’s 1u << 4 flags are; and the
constants used as case labels, in initialisers and as values. An
enumeration’s type is the one this cc gives it: an unsigned int
when none of its constants is negative, and an int when one is.
Refused, as cc refuses them: an enumerator declared twice or named as
something else already is, one past an int’s range, an enumeration
defined twice or named before its list, an assignment, ++ or & of a
constant, and a value that is no constant. Refused by name, though cc
compiles it: a value from a character constant, A = 'a'.
Tests: 639 → 649. Eleven refusals. Two programs join the C oracle:
322 agree with cc, none diverges. Refusals: 409.
2026-10-01: C _Bool
_Bool works, the third part of ROADMAP
6.15, and so does bool from
<stdbool.h> once cc -E has made it _Bool: one byte, and always 0
or 1, whatever it is given, a long of 2^32 or an int of 256 among
them, in an assignment, +=, --, an initialiser, a global, a cast, a
return and an argument. A function may return one, and cc’s code and
this one’s agree on how one is passed and returned.
Refused, as cc refuses it: unsigned _Bool. One program refused by
name until now compiles.
Tests: 639 → 639, one refusal replaced by another. Three programs join the
C oracle, one a refusal until now: 320 agree with cc, none diverges.
Refusals: 399.
2026-10-01: C short
short works, the second part of ROADMAP
6.15: short, short int and
unsigned short, two bytes, wrapping at sixteen bits, in arrays, structs,
globals and statics, as parameters and return values, and through
.... A function returning one hands it back as cc expects.
Refused, as cc -pedantic-errors refuses them: short long and short
short. One program refused by name until now compiles.
Tests: 638 → 639. Two refusals. Three programs join the C oracle, one
a refusal until now: 317 agree with cc, none diverges. Refusals: 399.
2026-10-01: C long long
long long works, the first part of ROADMAP
6.15: long long, long long int and
unsigned long long, eight bytes as long is on this machine, and the
suffixes LL, ll, ULL and LLU in a constant, in hex too. signed
char already worked, as char, which is signed here.
Refused, as cc refuses it: 1lL, whose letters are in two cases. Two
programs refused until now compile.
Tests: 639 → 638, two refusals by name replaced by one. Four programs
join the C oracle, two of them refusals until now: 314 agree with cc,
none diverges. Refusals: 398.
2026-10-01: C pointers to functions closed: typedefs, returns, and types checked
The types around pointers to functions work, the fourth and last part
of ROADMAP 6.14, which closes
it: a typedef of a function’s type, typedef int binop(int, int); and
binop *f, a function returning a pointer to a function, written out,
int (*pick2(int))(int, int), or through a typedef, and called at once,
meta(1)(2)(9, 4); printf called through a pointer; and casts between
two such types. ?: and the comma give a function as a pointer to it.
A pointer to a function is given only its own type, as cc insists:
one of another type, a pointer to an object, a void * or a number is
refused, either way, at an assignment and an initialiser.
Two old messages are corrected: a typedef of a function’s type is no
longer refused, and a function’s definition declared typedef says that
C forbids it. A function declared before its definition, returning a
pointer to a function, was refused as declared twice, and is not.
Refused by name, though cc compiles it: a function declared through a
typedef of its type, binop add;.
Tests: 628 → 639. Eleven refusals. Three programs join the C oracle,
one of them a refusal until now: 310 agree with cc, none diverges.
Refusals: 399.
2026-10-01: C pointers to functions in structs and in data
A function’s address is data, the third part of ROADMAP
6.14: a global, a static and a
table of them initialised with functions’ names, in braces, by position
and by designator, as Proem’s {expr_report, pp} and .skip =
already_included are, and libc’s functions as much as this file’s. A
struct member that points at a function is called as d->report(...),
and tested against 0 first, which the earlier parts had already made
work.
A false refusal is fixed: int (*g)(int) = *twice;, where * of a
function is the function, was refused as not worked out before the
program runs.
Refused, as cc refuses them: a global initialised from another global’s
pointer to a function, alone or in braces.
Tests: 626 → 628. Two refusals. Two programs join the C oracle: 307
agree with cc, none diverges. Refusals: 388.
2026-10-01: C calls through pointers to functions
A call may be through a pointer, the second part of ROADMAP
6.14: op(5, 3), (*op)(5, 3),
table[i](7, 2), (**pp)(5, 6), and a parameter called inside the
function it was passed to. Arguments are converted, structs passed and
returned, small and large, and longs widened, as in a call by name.
Compiling is faster: a call nested in another call’s arguments was read again for every way the grammar tried to go on after it, so four levels deep took nine seconds. It takes a few hundredths.
Two false refusals are fixed: a pointer to a function returning
void was refused as an object declared void, and calling through one
with * was refused as * of a void *.
Refused, as cc refuses them: a call of something that is neither a
function nor a pointer to one, the wrong number of arguments through a
pointer, and a number given where a pointer’s parameter is a struct. An
undeclared name inside a call’s arguments is refused as a name, not as a
call.
Tests: 619 → 626. Six refusals. Two programs join the C oracle: 305
agree with cc, none diverges. Refusals: 386.
2026-10-01: C pointers to functions, as values
A function is a value, the first part of ROADMAP
6.14: int (*op)(int, int) = add;,
&add, *op, arrays of them, static ones, typedefs of them, casts and
sizeof with int (*)(int) written out, and ==, != and a test
against 0 between them. One can be passed to a function that calls it,
so qsort and bsearch from libc sort and search with comparators
written in C. Calling through one yourself is the next part.
Refused, as cc -pedantic-errors refuses them: arithmetic, ++ or an
order on pointers to functions, sizeof of a function, and an assignment
to a function or through * of a pointer to one. Two programs refused by
name until now compile.
A false refusal is fixed: a parameter named in a pointer to a
function’s parentheses, int (*f)(int T);, hid a typedef T until the
end of the block, so a declaration of T x; after it was a syntax error.
Tests: 614 → 619. Seven refusals. Five programs join the C oracle,
two of them refusals until now: 303 agree with cc, none diverges.
Refusals: 380.
2026-10-01: C compound literals, and initialisers closed
Compound literals work, the fourth and last part of ROADMAP
6.13, which closes it:
(struct pair){3, 4}, (int []){1, 2, 3}, (int){5}, with designators
and strings as any initialiser has them. In a block one is filled each
time it is reached; at file scope it is data, so int *p = &(int){42};
works. One is used as a name is: &, =, a subscript, a member,
sizeof, passed whole, or decaying to a pointer.
Two messages are corrected: a struct global or static initialised
from another struct’s value says, as cc does, that its value is not
worked out before the program runs. It said braces were not here yet.
Refused: a compound literal given more values than it has room for, {},
or a string longer than it; one of void, of a struct not complete, or of
variable length; a block’s compound literal in a static’s initialiser;
and a struct’s compound literal initialising a global, which cc takes as
an extension and C11 does not. Three refusals 6.13 listed are now tested
too: an array or a struct from a scalar, and an array with neither a size
nor an initialiser.
Tests: 601 → 614. Thirteen refusals. Two programs join the C oracle:
298 agree with cc, none diverges. Refusals: 375.
2026-09-30: C designated initialisers, and braces left out
Designated initialisers work, the third part of ROADMAP
6.13: .member = and
[index] = , chains of them, {.t.kind = 5, .n[2] = 7}, in any order,
with values after one going on from where it left off. The last value
given for a place is the one kept. An array of no stated size is sized by
its furthest designator.
Braces may be left out wherever C allows, inside structs too, which
the previous part refused: {'e', 7, 8, 9, 10, 11} fills a struct whose
second member is an array and whose third a struct.
A compile failure is fixed: a struct member more than 4095 bytes in produced assembly the assembler refused.
Refused: a designator past an array’s end, naming no member, or of the wrong kind; an index that is an expression and not a number, for now; a struct over 32767 bytes or 86 members, and an object nested more than eight levels deep, in an initialiser.
Tests: 593 → 601. Nine refusals. Three programs join the C oracle,
one of them a refusal until now: 296 agree with cc, none diverges.
Refusals: 362.
2026-09-30: C structs initialised in braces
Structs can be initialised in braces, the second part of ROADMAP
6.13: member by member,
struct pair p = {1, 2};, at file scope, in a function and static, with
members that are arrays or structs in their own braces, a member of char
from a string, a struct member or an array element from a struct’s value,
{0}, and arrays of structs, CPP’s own {{"+", 1}, {"-", 2}} among them.
Too many values, and a struct of the wrong kind, are refused.
One C form is refused for now: leaving out the braces of a member that
is itself an array or a struct, {'e', 7, 8, 9} for a struct whose second
member is int[3]. C allows it; nothing in CPP writes it.
Tests: 588 → 593. Five refusals. Two programs join the C oracle: 293
agree with cc, none diverges. Refusals: 354.
2026-09-30: C initialisers in braces for arrays, and large frames
Arrays and scalars can be initialised in braces, the first part of
ROADMAP 6.13: int
primes[5] = {2, 3, 5, 7, 11}; at file scope, in a function and static,
with inner braces or without, int x = {5};, and an array of no stated
size taking its size from its values, int sized[] = {10, 20, 30};. A
string fills an array of char, char word[] = "hello";, and so does a
list of them, months[][4] = {"Jan", "Feb", "Mar"}. What is not given is
zero. Too many values, a string too long, {} and a value at file scope
that is not a constant are refused.
A compile failure is fixed. A function whose local variables took more
than 255 bytes of its frame produced assembly the assembler refused, for
any variable past that point: int big[80]; int i = 0; did not assemble.
Such variables are now reached through a register.
Tests: 577 → 588. Eleven refusals. Three programs join the C oracle:
291 agree with cc, none diverges. Refusals: 349.
2026-09-30: C arrays of arrays and pointers to arrays; declarations done
C types can be built by declarators, the fourth and last part of
ROADMAP 6.12, which
closes it: int a[2][3], int (*p)[3], int (*q[2])[3], sizeof(int
*[3]), casts to a pointer to an array, a parameter declared int b[][3],
and typedef int row[3];. A pointer to an array steps by the array’s
size, and a row of an array of arrays decays as an array does. A pointer
to a function, int (*f)(int), is refused by name for now.
Declarations as C11 has them are done: the words of a type in any
order, several names in one declaration, extern, tentative definitions,
_Static_assert, struct tags in any scope, and now types built by
declarators.
Tests: 572 → 577. Six refusals. Three programs join the C oracle,
one of them a refusal until now: 288 agree with cc, none diverges.
Refusals: 338.
2026-09-30: C struct tags, and a miscompile fixed
A wrong answer is fixed. Pointer arithmetic through a struct member
that points at its own struct, p->next + 1 in a struct node, counted
in elements of no size, so the program ran and read the wrong element. It
had been so since structs arrived on 2026-09-23. The width of a struct is
now found where a pointer to it is used, not where the pointer was
declared.
Structs can be declared where C allows, the third part of ROADMAP
6.12: inside a function
with the function’s or the block’s scope, with no tag, inside another
struct, and before they are defined, struct later;. A pointer to a
struct that is not complete yet is allowed, and an object of one, its
*, sizeof and arithmetic are refused until it is, as cc refuses
them. A typedef may be declared inside a function.
Tests: 568 → 572. Ten refusals. Ten programs join the C oracle, six
of them refusals until now: 285 agree with cc, none diverges.
Refusals: 333.
2026-09-29: C declarations of several names, extern, and _Static_assert
A C declaration may declare several names, the second part of ROADMAP
6.12: int a = 1, b, *p;
at file scope, in a function and in a struct, and typedef int I, *IP;. A
for may declare its own variables, which end with the loop. A prototype
may leave its parameters unnamed, int add(int, int);.
extern works, at file scope and in a block, and a global may be
declared more than once, as C11 allows: int x; int x = 5; compiled to a
refusal until now. Its storage is written once, and a second declaration
with another type, or a second initialiser, is refused. A static name
declared again with extern stays its file’s own.
_Static_assert works, at file scope and in a function, and a failing
one prints its message, as cc does. A definition with an identifier list,
int old(a, b) int a; long b; { ... }, compiles when its declarations come
one name each and in the list’s order, and is refused by name otherwise.
Tests: 552 → 568. Twenty refusals. Seven programs join the C oracle,
four of them refusals until now: 275 agree with cc, none diverges.
Refusals: 329.
2026-09-29: C’s specifiers in any order, and every keyword reserved
The words of a C type may come in any order, the first part of
ROADMAP 6.12, the first
entry of the C11 arc: int long, long unsigned int and char signed are
the types C11 says they are, and a list of words that is no type, long
char or int int, is refused. A storage class may stand anywhere among
them, int static f(void) and int typedef count;, and only one is
allowed. auto and register arrive for locals, and a register
parameter; taking a register local’s address is refused, and so is
either word at file scope.
All 44 of C11’s keywords are reserved. Until now 21 of them could be
used as names, so int float = 1; compiled where cc refuses it. Those
whose feature is not in the subset yet are refused by name wherever they
are used: float, double, short, _Bool, enum, union, extern,
volatile, restrict, inline, _Noreturn, _Alignas, _Alignof,
_Generic, _Static_assert and _Thread_local, and long long.
_Complex, _Imaginary and _Atomic are optional in C11, and the
refusal says so.
Tests: 500 → 552. Fifty-four refusals. Three programs join the C
oracle, two of them refusals until now: 268 agree with cc, none
diverges. Refusals: 313.
2026-09-29: const in C
The C subset has const, the third part of ROADMAP
6.10, which closes it. It may be
written before or after a type’s base, after any *, and twice. A write to
something const is refused, as cc refuses it: by =, += and the
other compound assignments, ++ and --, whether the thing is a variable,
a parameter, a member, an element, or what a pointer to const points at.
So is assigning a struct whole when a member of it is const. const and
static are reserved words now. A pointer to const given to a pointer
without it is not checked, which cc only warns about.
Tests: 468 → 500. Thirty-two refusals. One program joins the C oracle:
265 agree with cc, none diverges. Refusals: 261.
2026-09-29: static locals in C
A local in the C subset can be static, the second part of ROADMAP
6.10: it keeps its value from one call to
the next, and is initialised once, before the program starts. Its
initialiser has to be worked out before the program runs, as a global’s
does, and is refused with the reason when it is not.
A refusal that named the wrong reason is corrected. A pointer
initialised with an address that is no constant in C, the address of a
local or a global pointer’s value, was refused as an address this compiler
cannot write yet, which is what &table[1] gets. It now says the value
cannot be worked out, as cc does.
Tests: 452 → 468. Sixteen refusals. One program joins the C oracle:
264 agree with cc, none diverges. Refusals: 229.
2026-09-29: static functions and globals in C
A function or a global in the C subset can be static, the first part
of ROADMAP 6.10: it is its own file’s,
and another file can have one of the same name. A declaration without
static after one with it is its file’s too, as C has it; the other order
is refused, as cc refuses it. static is a reserved word now, so int
static = 1;, which compiled until today, is refused. It has to be the
first word of a declaration here: int static f(void) is C, and a syntax
error. static inside a function, and const, are the next two parts.
Tests: 444 → 452. Seven refusals, and a test that links two files, each
with static names of the other’s, compiled by Phoenix and by cc in
every pairing. One program joins the C oracle: 263 agree with cc, none
diverges. Refusals: 213.
2026-09-29: a refused constant says why
When the C subset refuses a case label or a global’s initialiser, it
now says why. Until now every one said the value cannot be worked out
before the program runs, which was true of a variable or 1 / 0 and not of
the rest. Now there are four answers:
- this one cannot be: C says it is not a constant, as for
case y:. - C leaves this one undefined: an overflow, like
9223372036854775807 + 1, or a shift past the width.ccfolds these with a warning. - this one is
unsigned longarithmetic past 2^63, which C works out and this compiler does not:sizeof(int) - 5,-sizeof(int),~sizeof(int). The limit is kept on purpose, and is now named as one. - this one shifts a
longby 63, which this compiler does not work out.
Where two parts of an expression disagree, the stronger reason is given.
Tests: 423 → 444. Twenty refusals, three of them written for
sizeof on 2026-09-27 and asserted by nothing until now, and one check
that every program in the C refusals is asserted by name. Refusals: 206.
2026-09-27: hex, octal and suffixes in C
An integer constant in the C subset can be hex, 0x1F, or octal, 017,
and can carry u, l or both, in either order and either case: the
third part of ROADMAP 6.9, which closes it.
Each has the type C gives it, which depends on its size, its suffix and
whether it is decimal: 0xffffffff is an unsigned int and 4294967295
a long, so -1 < 0xffffffff is 0. 1L and 9223372036854775808
compile; both were refused until now.
Constant expressions wrap as unsigned int. A case label or a
global’s initialiser can cast to an unsigned type, use ?: with an
unsigned answer, or wrap, 4294967295u + 2 being 1. In a switch on an
unsigned int a label is converted to one, so case -1 matches
- An
unsigned longexpression that passes 2^63 is still refused where a constant is wanted, except a comparison.
Refused, as cc refuses them: an octal constant with an 8 or a 9,
0x with no digits, a suffix twice, and a constant too big for any type.
1ll is refused too, long long not being in the subset.
Tests: 416 → 423, for eleven refusals, less four checks whose programs
now compile. Seven programs join the C oracle, five of them moved from the
refusals: 262 agree with cc, none diverges. Refusals: 189.
2026-09-27: sizeof is unsigned
sizeof is an unsigned long in the C subset, as C’s size_t is on
this machine, the second part of ROADMAP 6.9. It
had been a long, and a program that subtracts past a size got every
answer wrong: sizeof(int) - 5 < 0 was 1 where C says 0. The difference
of two pointers stays a long.
A constant with unsigned arithmetic in it folds only where signed and
unsigned agree: operands and answer from 0 to 2^63. So case
sizeof(long) - 4: compiles, and case sizeof(int) - 5:, -sizeof(int),
~sizeof(int) and -1 < sizeof(int) are refused where a constant is
wanted, which cc folds. Constants that wrap are the third part.
Tests: 416 → 416. None added: the C oracle and its refusals are one
check each. Two programs join the oracle: 255 agree with cc, none
diverges. Four join the refusals, 183.
2026-09-26: the website builds again
The website at the project’s GitHub Pages address is current again. From 2026-09-05 no build of it succeeded, fifty-five in a row, and the site went on showing the pages as they were that day: none of the C work, and none of the documents written since. The site’s description had gained a colon that YAML reads as the start of a key, so the build stopped before it read a page. The description is quoted, and the first build after it deployed.
Tests: 416 → 416. None added: nothing in make test builds the site.
2026-09-26: unsigned types in C
The C subset has unsigned char, unsigned int and unsigned long,
the first part of ROADMAP 6.9, and signed before
char, int or long. Their arithmetic wraps, and /, %, >> and the
comparisons are the unsigned ones, with C’s usual conversions deciding which
applies: -1 < (unsigned int)1 is 0, because the -1 becomes 4294967295
first, while a long beside an unsigned int stays signed. An unsigned
char reads as 0 to 255. The words of a type come in one order here, and
long unsigned is declined by name, as is, until constants learn to wrap,
a case with a cast to an unsigned type or a negative one in a switch on
an unsigned int. Suffixes such as 4294967295u and hex constants are the
next part.
Tests: 410 → 416, for six refusals. Two programs join the C oracle: 253
agree with cc, 179 are refused, none diverges among them. One program
Phoenix compiles today still does, sizeof being a long here; that is the
second part.
2026-09-26: int refuses a number too big for 64 bits
The library’s int refuses text that does not fit in sixty-four bits.
It read "9223372036854775808" as 9223372036854775807, the nearest integer
that fits, and said nothing, where every other integer that does not fit
stops with an error. A description that reads numbers out of a source with
int now hears about one too big, and written-out compilers carry the fix.
In C, a constant one past the largest long is refused by name. C makes
it an unsigned long, which is ROADMAP 6.9’s. A
case of one had been folded to the largest long.
Tests: 408 → 410, for the refusal and a check that it is said once. One
program joins the C oracle, the largest and the most negative long: 251
agree with cc, 173 are refused, none diverges.
2026-09-26: a global initialised with an address
A C global can be initialised with an address, which closes
6.8: char *greeting = "hello";,
int *where = &answer;, int *row = table;, each written into the program
as the address it names, as cc writes it. Declined by name, although cc
compiles them: an address with a number added, &table[1], table + 2 or
"hi" + 1. A global pointer initialised from another global pointer is
refused, as cc refuses it.
Tests: 403 → 408, for five refusals. One program joins the C oracle:
250 agree with cc, 172 are refused, none diverges.
2026-09-26: global variables in C
A C program can declare a variable outside every function, the second
part of ROADMAP 6.8: int count;, long big =
4294967296 * 2;, char table[16];. One with no initialiser starts at
zero, and one with an initialiser is given it when the program is compiled,
converted to its type as an assignment would be, so char c = 200; is -56.
A local or a parameter of the same name hides a global inside its function,
and a function above a global cannot see it. A global defined twice, one
named like a function or a typedef, a void one, and one initialised with a
call or with another global are refused, as cc refuses them. Declined by
name, although cc compiles them: a global declared twice where one has no
initialiser, which C allows, and an array of no elements. An initialiser
that is an address, char *s = "hi";, is the next part.
Tests: 390 → 403, for thirteen refusals. Two programs join the C
oracle: 249 agree with cc, 167 are refused, none diverges.
2026-09-26: long constants in C
A constant long expression is worked out when the program is compiled,
the first part of ROADMAP 6.8. Until now only
arithmetic on numbers that fit in 32 bits was, which was enough for a case
in an int switch and not for one in a long switch: case 4294967296 *
2: was refused, and is now 8589934592, as cc has it. An answer that does
not fit in 64 bits is an overflow, which C leaves undefined; cc wraps it,
and a case of one is refused here by name, as is an int shifted by 32 or
more.
Tests: 386 → 390, for four refusals. One program joins the C oracle: 247
agree with cc, 154 are refused, none diverges.
2026-09-26: casts in C, and a failure passed through and and or
The C subset has casts, which closes
6.7: (long)a * a multiplies in 64 bits,
(char)200 is -56, (char *)&x reads an int’s bytes, and (void)x
throws a value away. A cast in a case label is worked out when the program
is compiled, so case (char)300: is case 44:, and two labels equal through
a cast are refused, as cc refuses them. A struct cast to a number, a void
value cast to anything but void, and an assignment to a cast are refused;
declined by name, although cc compiles it, a cast to a struct.
A description’s checks say one thing once, again. When the right side of
and or or in a description was an attribute whose check had already
failed, Phoenix reported a second error, naming a line of the description,
after the right one about the source. The left side has always been passed
over quietly, and now the right is too. Written-out compilers carry the fix.
Tests: 379 → 386, for seven refusals. Four programs join the C oracle:
246 agree with cc, 150 are refused, none diverges.
2026-09-26: void in C
A C function can return void, the first part of
6.7. return; leaves one, and falling
off its end does too. f(void) declares a function of no parameters, as
int main(void) does. void * converts to and from any other pointer
without a cast, so void *malloc(long n); is how malloc is declared now.
A void value used as a number, return; in a function that returns one, a
value returned from a void function, and a variable, parameter or member
declared void are refused, as cc refuses them. Declined by name, although
cc compiles them: sizeof(void), arithmetic on a void *, main
returning void, and a typedef of void itself.
Tests: 366 → 379, for thirteen refusals. Two programs join the C
oracle: 242 agree with cc, 143 are refused, none diverges.
2026-09-26: printf in C
A C program can call printf, and any function declared with ...,
with as many arguments past the named ones as it likes. That closes
6.6. The extra arguments go where
Apple’s arm64 puts them, on the stack in slots of eight bytes, so printf
from the system library reads them: %d, %ld, %c and %s all work,
with ten arguments or none. A call with fewer than the named arguments is
refused, as cc refuses it. Declined by name, although cc compiles it: a
struct passed through ....
Tests: 364 → 366, for the two refusals. Three programs join the C
oracle: 240 agree with cc, 130 are refused, none diverges.
2026-09-26: a C function declared with ...
A C function can be declared or defined with ... after its
parameters, the first part of 6.6.
So int printf(char *format, ...); is now a declaration the subset reads,
and printf("hello\n") works. A call still passes exactly the named
arguments: passing more is the second part. ... with no parameter before
it, ... before another parameter, and a function declared with ... in one
place and without it in another are refused, as cc refuses them.
Tests: 361 → 364, for the three refusals. One program joins the C
oracle: 237 agree with cc, 128 are refused, none diverges.
2026-09-25: switch, and constants worked out before the program runs
The C subset has switch, case and default, which finishes
the statements. A case can sit anywhere
inside its switch, inside a loop inside it included, as Duff’s device has
it; falling from one case into the next, default anywhere, and break
leaving the switch all work as in C, and continue in a switch goes on
with the loop around it.
A case label is worked out when the program is compiled, as C
requires: 'a' + 1, 1 << 3, -7 / 2, sizeof(int), ~0 & 7,
1 ? 40 : 1 / 0 and the like, with C’s arithmetic, a truncating / and
32-bit ints. Constant expressions
are a pass of their own, which array sizes, enum and globals will use too.
A label that is not constant, two cases with one value and two defaults
are refused, as cc refuses them. Declined by name, although cc compiles
them: a comma in a label, which C11 forbids and clang allows, and a label
too wide for an int switch.
Tests: 350 → 361, for eleven refusals. Six programs join the C oracle:
236 agree with cc, 125 are refused, none diverges.
2026-09-25: bitand, bitor and bitxor in the library
The notation changed. Three functions join the library, next to
quotient and remainder:
bitand(12, 10) = 8 bitor(12, 10) = 14 bitxor(12, 10) = 6
Each takes two integers and works on their sixty-four bits, a negative
integer being its two’s complement, so bitxor(x, -1) is -x - 1. None can
overflow. They are functions and not operators because and, or and not
already belong to the booleans. A description that works out another
language’s constants before its program runs needs them, and could not
write them before in + - * div mod; C’s case A | B: is the first,
ROADMAP 6.5. A compiler written out
with -o has them too. semantics.md specifies them, and the
VS Code extension colours them.
Tests: 350 → 350. None added: the notation’s own claim files, run
through phx and through a compiler it writes, check eleven more claims,
and the library’s refusals one more, a float given to bitand.
2026-09-25: goto and labels in C
The C subset has goto and labels, the second part of
ROADMAP 6.4. A label belongs to the whole
function, so a goto can jump forwards or backwards, out of loops and into
blocks, and two functions can each have a label of the same name. Labels
are a name space of their own, as C says, so a label and a variable may
share a name. A goto to a label its function does not have, and a label
defined twice, are refused, as cc refuses them.
Tests: 347 → 350, for the three refusals. Two programs join the C
oracle: 230 agree with cc, 114 are refused, none diverges.
2026-09-25: break, continue, do and the empty statement in C
The C subset has break, continue, do … while and the empty
statement ;, the first part of ROADMAP 6.4.
break leaves the innermost loop and continue goes on with it: in a for
that runs the step, and in a do the test at the bottom. A do runs its
body once before it tests. break or continue outside every loop is
refused, as cc refuses it. do, break and continue are now reserved
words; before this, break; was read as a variable nobody had declared.
Tests: 344 → 347, for the three refusals. Five programs join the C
oracle: 228 agree with cc, 111 are refused, none diverges.
2026-09-25: the bitwise operators, the shifts, ?: and the comma in C
The C subset has every expression operator C has except the cast. The
last part of the operators brings &, |,
^, ~, << and >>, their compound assignments &=, |=, ^=, <<=
and >>=, the conditional ?:, the comma operator and unary +. They take
C’s precedence, so a & b == c is a & (b == c) as it is in C. >> of a
negative number shifts in its sign, as cc does here, and a shift is the
type of its left side. ?: runs one arm, and takes two numbers, two pointers
to one type or two of the same struct. A comma is two arguments inside a
call, as C says, and an operator anywhere else.
Two things cc compiles are refused: ?: with a pointer on one side and 0
on the other, and ?: with pointers to two different types.
Tests: 333 → 344, for eleven refusals, nine of them ones cc makes too.
Six programs join the C oracle: 223 agree with cc, 108 are refused, none
diverges.
2026-09-25: ++, -- and the compound assignments in C
The C subset has ++ and --, before and after, and +=, -=, *=,
/= and %=, the third part of ROADMAP 6.3.
The place is worked out once, so x[k++] += 10 moves k once and a call in
a subscript runs once. A pointer steps by what it points at, twelve bytes for
a struct of three ints. The arithmetic is done at the wider of the two
sides, so an int divided in place by a long is divided by all of it, and
a char is narrowed on the way back, as an assignment narrows it. They chain
as C’s postfix operators do: q++->c and *p++ are what they are in C.
Tests: 325 → 333, for eight refusals, each one cc makes too. Six
programs join the C oracle: 217 agree with cc, 97 are refused, none
diverges.
2026-09-25: an int from a function cc compiled is read correctly
Fixed: a C program compiled by Phoenix could misread an int returned by
code another compiler built. The calling convention leaves the upper half
of the register unspecified when a function returns an int, and Phoenix
tested the whole register in every if, loop condition, !, && and ||.
A library function that returned a narrowed long, as
int f(long x) { return x; } does, could make a condition true that was
false. Phoenix now clears the upper half after every call that returns an
int. Functions Phoenix compiled were never affected, which is why the C
oracle, which compiles each program with one compiler, could not see it;
tests/abi/, which links the two, now does.
Tests: 325 → 325. None added: the calling-convention check asks more of the same pairing.
2026-09-25: !, && and || in C
The C subset has C’s logical operators, the second part of
ROADMAP 6.3. && and || evaluate their
right side only when the left has not already decided the answer, so
p && *p is safe and a call on the right of a decided || is never made.
All three are worth 0 or 1, an int, whatever they were given: 0 || 3 is
- They take numbers and pointers, a
longtested on all its 64 bits, and refuse a struct asccdoes.
Tests: 322 → 325, for the three refusals. Five programs join the C
oracle: 211 agree with cc, 89 are refused, none diverges.
2026-09-25: % in C
The C subset has %, the remainder, on int, char and long
operands, the first of the operators ROADMAP 6.3
lists. It truncates toward zero as / does, so -7 % 3 is -1 and 7 % -3
is 1, and an int meeting a long is widened first. A pointer or a struct on
either side is refused, as cc refuses it.
Tests: 320 → 322, for the two refusals. Four programs join the C oracle:
206 agree with cc, 86 are refused, none diverges.
2026-09-25: .phx in colour, in VS Code
editors/vscode/ is an extension for .phx descriptions: comments,
literals and the {} holes in them, directives, the rule a production
defines, the attribute a pass clause defines, references, nodes, labels and
the library are each coloured, brackets match, and Cmd+/ writes (* … *).
It is installed by linking the directory into ~/.vscode/extensions, which
its README shows.
It is checked against the notation’s own description of itself:
languages/phx/phoenix.phx lexes every .phx file in the repository, and
the colouring has to agree with it about every comment and every literal.
python3 editors/vscode/test.py runs that; it is not part of make test,
which needs nothing outside the repository.
2026-09-25: a C function can return a pointer
int *f() compiles. A function in the C subset returned an int, a
long or a struct; it can now return a pointer, to any of them, written
with stars before its name or through a typedef. That is what lets a program
declare malloc, with a char * prototype, and use what it gives back. A
function returning a pointer to a struct was the care the change needed: it
has a struct’s tag and is not a struct, and three places in the compiler
took one for the other until each was asked for no stars as well.
A long index into a pointer, built on 2026-09-23 as a 64-bit multiply and
add, had no program that could tell it from the 32-bit one it replaced,
because only an object over 2 GB could. One now does: it mallocs three
billion bytes and indexes near the end. void is still not in the subset.
Tests: 321 → 320. The check that refused a function returning a pointer
is gone, and its program is one of six new ones the C oracle holds against
cc: 202 agree, 84 are refused, none diverges.
2026-09-24: a test that never finishes fails instead of hanging
make test can no longer hang. Every program the suite runs, phx
itself, everything phx or cc has just made, and awk and solvm
running what phx wrote, runs under a limit of 20 seconds, in every one of
the fifteen harnesses as well as in tests/run.sh, and each harness runs
under a limit of 600. A
program that loops forever is stopped and its check fails with did not
finish in the reason. So is one that prints forever, which is the
likelier regression and which time alone did not catch: it wrote so much
that the shell capturing it ran out of memory and took the suite down with
it. A program is stopped after 16 MB of output as well. The limit is
tests/limit.c, built by make as bin/limit,
because macOS has no timeout. PHX_LIMIT, PHX_HARNESS_LIMIT and
PHX_LIMIT_MB change the three numbers. The suite takes as long as it did.
A stop fails the run even where the check around it would not notice.
Every stop is logged, and the last check fails, naming each program and its
arguments, if anything was stopped anywhere. A check that expects a failure
and discards what the program said would take a stop for the failure it
wanted, and an oracle whose two programs are both stopped sees them agree.
The first run with the log found one of those: the Solveig bytecode oracle
compares solvm --trace output, and sola.sol’s is 49 MB, so both sides
were cut at 16 MB and matched. That oracle now allows its traces 256 MB,
and its compiles 60 seconds, since sola.sol takes 8 to compile. It used to
run solvm under a perl alarm, which reported nothing when it fired.
Tests: 317 → 321. Three for the limit itself, which everything else now runs through: a program that finishes keeps its exit status, one that never finishes is stopped and says so, and so is one that never stops printing. And one for the whole run: every program the suite ran finished.
2026-09-23: typedef, a struct copied and returned whole, and the first thing a parse remembers
The notation changed. %names is a new directive: a table of names the
parse keeps while it matches, and one rule that may ask it.
%names typedef-names
declare Typedef.name
hide Param.name Local.name LocalInit.name LocalArray.name
scope block function
guard typedef-name .
typedef-name = name .
A node the grammar builds declares a name or hides one, at the moment
the name is read; a scope rule ends what was bound inside it; and a
guard rule matches a name only when the table says it is declared. A
failed alternative takes back what it bound, so ordered choice behaves as it
did before. It is written into compilers made with -o as well. Five
mistakes in a %names are refused when the description is read, with the
line.
The C subset has typedef, which is what the directive is for. typedef
int number;, typedef int *intp;, a typedef of a typedef, and typedef
struct node *list; all compile, and a typedef can be a parameter’s type, a
member’s and a sizeof’s. x * y; is a declaration when x is a typedef
and a multiplication when it is not, and a variable declared with a
typedef’s name hides it until its block or function ends, as C says. A
typedef declared twice for two different types is refused. Not yet: a
typedef inside a function and a typedef of an array. sizeof and the difference of two pointers are
still ints, because what C makes them is a long, which the subset does
not have. Thirteen more oracle programs, 156 in all, and 58 refusals.
A struct is copied whole: a = b, struct t u = s;, and a struct
passed to a function, all of which were refused. Passing follows the
machine’s calling convention, so a struct goes between code Phoenix compiled
and code cc compiled, in either direction, and a new test links the two to
make sure. A struct of one kind copied into another is refused, as cc
refuses it. So is a struct used where C wants a number: in arithmetic, a
comparison, a condition, a return from a function that returns an int,
or put into an int or a pointer.
Those compiled before today, into the struct’s address used as a
number, and cc refuses every one. Nothing in Phoenix itself changed for
any of this. Nineteen more oracle programs, 175 in all, and 73 refusals.
A function can return a struct: struct point make(int x, int y), and
a typedef of a struct or of int as the return type. It follows the
machine’s calling convention, so a struct comes back from cc’s code to
Phoenix’s and the other way, and the same test that links the two for
arguments now does it for returns. A call that returns a struct can be
copied, passed on, or have a member read, make(1, 2).y, but not have one
assigned or its address taken, which cc refuses too. So is returning one
kind of struct from a function declared to return another, or declaring a
function twice with two return types. A function returning a char or a
pointer is refused by name, although cc compiles both, and so is a main
that returns a struct, which cc only warns about. Ten more oracle
programs, 185 in all, and 82 refusals.
The C subset has long: signed, eight bytes, as a variable, a
parameter, a member, a return type and behind a typedef. int and long
mix as C says, the int converted first, so -1 stays -1 when it is widened.
A decimal constant too big for an int is a long. sizeof and the
difference of two pointers are longs now, as they are under cc, so the
two programs that gave 4 where cc gives 8 agree, and nothing in the C
subset diverges. A long passes to and from cc’s code in either
direction. Not yet: unsigned, and a suffix such as 1L. Nine more oracle
programs and the two former divergences, 196 in all, and 85 refusals.
The notation changed again: a driver can run a pass until something settles.
%driver code = layout, relax until labels, reach, code -> out .
relax until labels runs relax again and again until the root’s labels
is what the round before left, and then goes on. A stage that never settles
stops after 256 rounds with a message naming it. until is not a reserved
word. The Z80 assembler uses it: br now always picks the shortest
encoding, where it used to leave some programs a byte or two long, and
two-rounds.z80, which was pinned as a divergence at 130 bytes, is 129.
Compilers written out with -o do the same.
phx refuses a name in a pass that nothing could answer, when the
description is read. $sig with no binding, field, attribute or embed of
that name was accepted and then reported once for every node that reached
it, or not at all if no program did. So is a thread or an inherited
attribute read by a pass other than its own, which no later pass can see.
Every description in this repository reads as it did.
Phoenix has a license: MIT, in LICENSE. There was none
before.
Tests: 264 → 317. Eight for %names, on a small grammar made to have
C’s problem and nothing else: what it parses, the same through a compiler
written out with -o, and five refusals and a warning about the directive
itself. Nine for typedef: four refusals of what C refuses too, three of
what this subset leaves out, and two of a local named in its own
initialiser, which were refused before today and are written down now.
Then sixteen for the copy: the calling convention against cc’s, and
eighteen new refusals, nine of them of programs that compiled into an
address, less the three refusals that became oracle programs. Then nine
refusals for a struct returned, six that cc makes too and three this
subset adds. Then three for a bare name: the two ways of getting one wrong,
and a grammar holding every kind that is right, so the check stays as
generous as the lookup it guards. Then seven for until: four refusals,
the program that needs a fourth walk and what one walk made of it, and a
compiler written out with -o running the loop. Then three refusals for
long, less the two checks that pinned the divergences it closed.
2026-09-22: & and *, and a left side that is a place
The C subset’s seventh construct: the address of a thing, and the thing a
pointer points at. int *p = &x; *p = 7; compiles, and so do int **q = &p,
a pointer parameter a function writes through, and a swap written the way C
programs write one.
What is on the left of an = is a place and no longer a name. A place is
a rule with two alternatives, a name or a * on anything, and unary & goes
through the same rule. That is C11 6.5.3.2’s the operand shall be an lvalue,
said once in the grammar. &1 and 1 = 2 are refused by the parser with a
position, rather than by a pass asking what kind of node it was handed.
A place is a second attribute rather than a second node. A Variable and
a Deref each answer both the value and the address of the value, and the
parent asks for the one it meant, so &*p emits exactly what p emits and
C11’s identity for it is never written down. The declarators int *p and
int **q are read and their stars counted; a name in the symbol table now
means a slot and a star count, as one pair in the one table that already
had a scope rule.
A third pass, types, which exists to refuse what would otherwise
mis-compile. Every value here is eight bytes, so a pointer taken for an
int is harmless, except that C11 6.5.6 counts p + 1 in what p points
at, and nothing in this subset has a size yet. So pointer arithmetic is
refused by name until arrays and sizeof bring a size, * on an int is
refused as 6.5.3.2 requires, and - and * on a pointer with it. Whether an
assignment or an argument has the type it was given is not checked: the
one program that would show the eight-byte int, a pointer put in an int
and taken out again, is one cc refuses to compile.
Sixteen more oracle programs and seven more refusals, 58 and 18 in all.
Every one of the sixteen agreed with cc on the first run.
Later the same day: int is thirty-two bits. It had been sixty-four,
which is a conforming implementation and was left open on 09-21 for the oracle
to settle. The oracle had never been asked: int a = 2000000000; int b = a + a;
return b / 1000000; exits 218 under cc and had exited 160 here, in a program
the subset could compile since its second construct. sizeof(int) is 4 and a
pointer stays 64 bits wide. On arm64 this costs one letter per instruction,
w or x chosen from the star count the types pass already had, because
w0 is the low half of x0 and writing it zeroes the half above; the stack
machine, the calling convention and the frame are all untouched. Three more
oracle programs, 61 in all, two of them written to catch a half-finished
narrowing and checked by half-finishing one.
Later again: sizeof. Both shapes, sizeof(int) and sizeof(int **)
and sizeof x and sizeof *p, and the operand is not evaluated, which
C11 6.5.3.4 requires and which two oracle programs check by putting an
assignment and a side-effecting call inside one. It wanted nothing from the
passes: while int is the only base type a star count already is a size, so
this reads the table the register letter comes from for its other column.
One number disagrees with cc, and is written down rather than refused.
C makes sizeof worth a size_t, which is a typedef, and this subset stops
before typedef, so sizeof is worth an int and sizeof(sizeof(int)) is
8 under cc and 4 here. languages/c/tests/divergent/ holds the program and
the suite pins both answers, so closing the gap fails with the old numbers
in it.
Later still: an array, and a frame measured in bytes. int a[10] and
int *a[10], sizeof a, and the decay C11 6.3.2.1 requires: a name that is
an array is a pointer to its first element everywhere except under sizeof,
so *a is element zero and int *p = a; is the same address. A local now
lives at a byte offset rather than in a numbered slot, because forty bytes
do not fit in a slot, which is the first time a declaration’s type reaches the
frame. Everything is still given a multiple of eight, because the distance
between two separate objects is not something a C program can observe.
Assigning to an array is refused, as cc refuses it; a zero-length array is
refused where cc takes it as an extension, which is what keeps a count of
zero free to mean not an array; and indexing, a[i] and a + 1, is the
rest of the roadmap item and is refused by name until it arrives. Eight more
oracle programs, 79 in all.
And last: indexing, which closes the item. a[i], a + 1, p - 1,
2[a], m[1][1] = 4 and &a[2]. A subscript is C11 6.5.2.1’s
*((E1)+(E2)), built as exactly that by the grammar, so it needs no node of
its own and anything the passes already said about * and + holds for it.
Pointer + and - now count in elements, as C11 6.5.6 says, with one arm64
add that sign-extends a 32-bit index, scales it by the element’s width and
adds it to the pointer. Adding two pointers and taking a pointer from a number
are refused, as cc refuses them. The difference of two pointers is an
int, the count of elements between them. C makes it a ptrdiff_t, which
is a typedef, so it takes the decision sizeof took and is pinned the same
way: sizeof(&a[1] - a) is 8 under cc and 4 here, and both answers are
asserted. Subtracting pointers to different types is refused, as C requires.
Twenty more oracle programs, 99 in all, 25 refusals and two divergences.
Then char, as a type. Locals, parameters, arrays and pointers of
char, sizeof(char), and the conversions C11 6.3.1 asks for. A plain char
is signed on Apple’s arm64, where Apple departs from AAPCS64, so it is
loaded with ldrsb; a store keeps the low byte, so char c = 300; holds 44;
and an assignment to a char is worth what the char holds afterwards.
Anything that is not a place or a pointer is promoted to int, so
sizeof(c + 1) is 4. A type is now two numbers, the stars and the width of
what is under them, and two pointers are the same type only when both agree.
Eleven more oracle programs, 110 in all, and 26 refusals.
Then character constants. 'a', and six escapes, \n \t \\ \' \" \0.
Each is an int, as C11 6.4.4.4 says, so sizeof 'a' is 4. Printable ASCII
only: a hex or octal escape, two characters between the quotes and a tab typed
between them are C, and are refused by the lexer as outside the subset. One
oracle program checks all ninety-five printable characters. Five more oracle
programs, 115 in all, and 29 refusals.
And string literals, which close the item. "hello, world" is an array
of char in static storage, one longer than its characters, decaying to a
char *: sizeof "abc" is 4 and "abc"[1] is 'b'. Programs can print
now, through a puts or putchar declared in the program, and the oracle
compares what they print as well as how they exit. The escapes are five of
the six character constants have: \0 is refused in a string, because C
reads octal digits after it. Two literals side by side, which C joins, are
refused too. printf is not reachable yet: it is variadic, and Apple’s arm64
passes variadic arguments on the stack. Eight more oracle programs, 123 in
all, and 31 refusals.
Last: struct, which completes step one. struct point { int x; int y; };
at file scope, then p.x, q->y, arrays of structs, structs inside structs,
member arrays, sizeof(struct point), and a struct holding a pointer to its
own kind, so a linked list compiles and walks. Members are laid out as cc
lays them out, each at the next offset its alignment allows and the whole
rounded to its widest member, so sizeof agrees for every struct the oracle
tried. A step through a pointer to a struct counts in structs of any size.
A struct is never copied whole here: assigning one, initialising one from
another and passing one to a function are refused, and a pointer to one is
the way to hand it around. A struct defined inside a function, one with no
tag, one with no members and a pointer to one nobody defined are refused too;
cc compiles all four. Twenty more oracle programs, 143 in all, and 49
refusals. Nothing in Phoenix itself changed through the whole subset,
which is what the C arc predicted when it began.
Tests: 224 → 264, forty-two in and two retired. The first thirteen were
all but one a refusal: the address of
something that is not a place and an assignment to one, both from the grammar;
a * on an int and on a name nothing declared; a - and a * on a
pointer; and pointer arithmetic, which after the ninth parameter is the
second thing in the C subset refused for being outside the subset rather than
outside the language. Then a sizeof of a name nothing declared and a * on
what a sizeof is worth, and the pin on sizeof(sizeof(int)), which asserts
both answers so that closing the gap fails with the old numbers in it. Last,
the two refusals of pointer arithmetic were retired by indexing, and four came
in: two pointers added, a pointer taken from a number, a subscript on an
int, and two pointers to different types subtracted. And a second pin
beside the first, on the size of a pointer difference, and a refusal of a
char * less an int *, three of character constants outside the
subset, and two of string literals. Then eighteen for struct: two from the
grammar, seven about a definition, four about a member asked of the wrong
thing, one about pointers to two different structs, and four about copying
a struct whole.
2026-09-21: a C subset begins, with cc as its oracle
A C compiler has its first six constructs: languages/c/.
phx --driver arm64 languages/c/c-arm64.phx prog.c > prog.s
cc prog.s -o prog && ./prog
int main(){return 42;}, then + - * / with parentheses, then unary minus
and the six comparisons, then a local int, then if, while, for and
blocks, then functions with parameters and calls: functions returning int
with up to eight int parameters, prototypes, a body of declarations,
assignments, expression statements, control flow and returns, expressions
over constants, locals and calls, and the two comment shapes.
The calling convention is AAPCS64 as Apple applies it: the first eight
arguments in x0 to x7, the result in x0. A caller pushes each argument
as it is evaluated, then loads the registers from the stack and drops it; a
callee stores each register into its parameter’s slot in the prologue, after
which a parameter is a local. Recursion, mutual recursion through a
prototype, and a call nested in an argument are in the oracle. A ninth
parameter is refused by name rather than mis-compiled.
C99 6.5.2.2 wants a declaration above every call, and cc enforces it,
so the gathered table awk uses for a call above its function is not what C
needs. The declared functions are a thread; a definition binds itself on the
way in so its body can recurse. A gather pass remains for the two questions
no order can answer, a function defined twice and one declared with two
arities.
Six of C’s fifteen expression levels; a comparison is an int worth 0 or 1
that chains to the left the way C11 says it does, and assignment is an
expression worth what it assigned, grouping to the right.
The first pass that can say no. locals is a symbol pass in c.phx: a
thread of what has been declared, set afresh at each function and saved and
restored around each block, and a slot counter that only grows. A name nothing
declared, one declared twice in a scope, one read after its block has closed,
a parameter declared again in the body, a call before its declaration or with
the wrong number of arguments, a function defined twice or declared with two
arities, and a ninth parameter, are refused with a position, and
tests/refused/ holds the eleven programs that show it. A second thread holds only the current block’s names,
because C11 6.2.1 lets an inner block shadow an outer one and a single table
cannot tell shadowing from redeclaring. The emit pass turns a
slot into an offset below the frame pointer and lowers sp past the locals in
the prologue, rounded to sixteen. c.phx is the grammar and the tree and has no opinion about a machine;
c-arm64.phx imports it and adds one emit pass, so the split is the one every
other language here makes. The target is arm64 assembly in the syntax cc
assembles on this machine, and cc assembles and links it.
The emit pass is a stack machine. Every expression leaves its value in
x0; a binary operator pushes the left operand, computes the right, and pops
the left into x1, sixteen bytes a push because sp must stay aligned. One
rule and a table of four instructions, because a general Binary rule above
Binary(op: "+") takes everything and phx refuses it at read time. The
prologue and epilogue are the ones the later constructs will fill in rather
than change. Falling off the end of a
function returns 0, which C11 5.1.2.2.3 requires of main and permits of the
rest.
The oracle is cc itself, the way fpc is Pascal’s and /usr/bin/awk is
awk’s. tests/oracle/run.sh compiles
each program twice and compares what the two exit with, which until a function
can be called is all a program can say. Forty-two programs, among them the
groupings the folds have to get right, a quotient that truncates toward zero,
a signed comparison, five locals whose slots must not overlap, a dangling
else, a for (;;) that only a return leaves, and two nested loops whose
labels must not collide. Every branch target is a label the program never
wrote, from a thread counted on leaving, the way languages/z80/ numbers
nothing and languages/solvm/ numbers its chunks. One of them, 3 > 2 > 1, is C11 as written and an
error to this cc by default, so the oracle downgrades that one warning by
name rather than lose the witness. Nothing in the directory has a
hand-written expected result, which is the rule ROADMAP 6 set for the arc.
On the roadmap. 6.1
was written on 2026-09-06 and this is its first step taken; the entry itself is
unchanged but for a marker at the construct that exists. Its three predictions
are not yet scoreable: the first is about reaching struct with no change to
the tool, and the tool has not been asked for anything.
Tests: 211 → 224. Thirteen new ones: that the description reads, one
that runs the oracle programs and reports how many agree with cc, and
eleven refusals from the functions and locals passes. The first
run of the suite said 212, because the check that the records’ counts match
the tree was failing on a missing row and is itself one of the 213.
2026-09-05 — a second calc backend, and a conformance rule that reaches a loop
calc compiles to awk: languages/calc/calc-awk.phx.
phx --run emit-awk languages/calc/calc-awk.phx prog.calc > prog.awk
awk -f prog.awk
An %import line, an emit pass and three drivers — no grammar, no typechecker
and no interpreter, because those are in calc.phx and have no opinion about
any target. It is the same twenty-four clause lines as the C backend, and
seventeen of them are identical to it character for character.
What it is for. Phoenix’s rule is that one description, run two independent
ways, gives one answer. One of those ways was --run eval, which cannot
interpret a branch or a loop — an attribute is computed once per node in one
walk. So until now every calc program with control flow in it was checked by a
single backend against an expected string somebody had typed into the suite.
There are two backends under those programs now, and the suite compares their
output with each other rather than with an expectation.
Why awk and not the parked Solveig backend. awk needs nothing this
repository does not already need — the Makefile itself builds
phoenix/runtime.h by piping through awk — and, more to the point, awk’s
numbers are doubles. A second backend that shared C’s arithmetic could only
catch a mistake in its own clauses; this one had to write calc’s truncating
division out as int(a / b) in a host whose / is floating, which is the
divergence semantics.md exists to prevent, met a second time in
a second host.
Nothing about the tool changed. This is a description, and the language a compiler emits was never Phoenix’s business.
Syntax errors point at the mistake. A parse that matched its goal and left input over used to be reported at the first leftover token, with the expected list from wherever the match had really got stuck — two places in one message, and the token named was usually legal where it stood. Since a start rule that is a repetition never fails outright, that was every syntax error in every language described here.
print a +;
^ expected -, (, integer or name, and found "print" (before)
^ expected -, (, integer or name, and found ";" (after)
parse.c already tracked the furthest point the match reached; one path threw
it away. The fallback is unchanged for a parse that never got past the
leftover.
A Z80 subset, and an assembler for it:
languages/z80/z80.phx.
phx --driver code --raw languages/z80/z80.phx prog.z80 > prog.bin
z80asm -o want.bin prog.z80 && cmp want.bin prog.bin
326 lines: immediate loads, inc, dec, the immediate arithmetic, the
absolute jumps and calls, the relative ones, labels, and a listing backend
beside the bytes.
And br, which is the point. It assembles to a two-byte relative jump when
the target is in reach and a three-byte absolute one when it is not — a size
that depends on a distance that depends on sizes. The description walks the
tree twice, and the second walk is the first one written out a second time,
because the notation has no way to say again: walk one has met no forward
label and assumes three bytes, walk two decides against walk one’s addresses,
which over-state and therefore only shrink.
That reaches the minimum on a program one round deep — tests/oracle/chain.z80
goes 131 to 129 — and misses by a byte on
divergent/two-rounds.z80, where
the second br shrinking is what brings the first into range on a third
walk nobody makes. No fixed number of walks is the answer, which is
ROADMAP 2.5 — now with the
customer, the witness, and a working prototype of the mechanism in front of
it.
The oracle is byte for byte, which none of the others are. z80asm
assembles the same four programs and the two binaries are compared whole —
where Pascal and Solveig agree about what a program prints, and a
consistently wrong translation can survive that.
A thread declared below the rules that update it is refused. It used to be
silent, and silence there is a wrong answer rather than a missing one: clauses
are classified as they are read, so the rule above the declaration got an
ordinary attribute and the rule below it got the thread, and the thread reached
every node with the value it started at. The message names the fix, and
otherwise is covered as well as the rules.
Tests: 189 → 211. Twenty-two new ones: six for the calc backend; one for <>
and or, whose clauses no calc program had ever reached; one holding the
records’ own counts against the tree; and two for a claim reference.md had
made since %rewrite shipped and nothing had run — a node built by a rewrite
keeps the position of the node it replaced, so a diagnostic from a later pass
points at the program rather than at the rule. And seven for the Z80 subset: one
that it reads, four refusals — an undefined label, one name at two addresses,
an immediate that does not fit its byte, a hand-written jr that does not
reach — five pinning the two walks and the one that needs a third, and the
oracle. And one for the defect below. The calc
backend’s tests need awk to run what it emits, in the same role cc already
plays for the C backend; the Makefile requires both to build phx at all. The
Z80 oracle needs z80asm, and skips without it.
2026-09-03 — an assembler, and documentation for a stranger
A fourth description, and a target rather than a language:
languages/solvm/. An assembly
language for SolVM and an assembler that emits .sob bytecode — 21 mnemonics,
labels, and blocks whose chunks nest. Phoenix can emit .sob and cannot read
it: a length-prefixed format needs the match to depend on a count it has just
read, and the notation has no computed repetition. solvm --dump stays the
reading half, and comparing what it prints for two producers of one program is
the oracle.
Slots may be addressed by name. Where a frame is declared as its slots’
names — slots self, n — an instruction may write local n and outer 1, n
rather than local 1. The name resolves against that frame’s own declaration
and the same byte is written, so the two spellings are one program;
programs/adder.sasm and adder-named.sasm are exactly that, and the suite
compares their bytes.
Two checks that used to be the loader’s. outer 0 and a depth past the
outermost frame are now refused at assembly time, at both ends, matching
serialize.c’s d < 1 || d > ancestor_count. So is a slot past the frame it
addresses, outer included. Every rule in SolVM’s verify_chunk is now either
guaranteed by construction or checked here, except the stack-height dataflow,
which a walk over a tree cannot do.
Notation: | became an expression operator, for awk’s cmd | getline
(1.6).
Tool: a field that shadows a thread or a synthesised attribute is now an error rather than a silent win; a failed check complains once rather than twice.
Settled against: scope graphs, from Statix (2.3). Turbo Pascal’s units were described to test it and resolution stayed an association list.
Documentation. A manual, reference and cheatsheet for Phoenix and for the
assembler, three tutorials, and a website at
hansolovkarlsson.github.io/Phoenix
assembled from docs/ on every push rather than committed. The tutorials are
executable: make test runs each one and holds the page to what actually
happens. Doing that the first time found eight defects in them.
This page, and two corrections it caused. Written from the stage-* tags,
it found COMPLETED.md’s stage table disagreeing with them at
rows 4 to 6 — and then found that the README holds
a second stage table which is the original plan rather than the delivery.
Both now say which they are. This page was itself wrong within the hour, and
that correction is recorded in journal.md rather than hidden.
Measured, a fourth time. ROADMAP 1.2 — whether the tables should be compiled to code — came out the same way again, now with the control it had been missing. The assembler’s grammar has no expression ladder, so it is what the matcher costs when a grammar asks nothing of it: 11–25 match-steps per token, against Pascal’s 12–31 and awk’s 221–2,638. Three grammars, 240× apart in constant and identical in curve, over nine shapes. performance.md has every number and the command that reproduces it.
ROADMAP 1.2 is closed, settled against building. The fourth measurement is what settled it: three flat curves with no control could not say why they were flat, and the assembler supplied one. Generating code buys a constant factor on a matcher already within 2× of a grammar that asks nothing, and costs a second implementation of ordered choice, floored division and pattern matching. The roadmap now has no open entries at all — sections 1 and 2 are empty, and what remains is what the project has decided not to have.
Fixed in the benchmark: bench/run.sh did not check whether phx had
succeeded, so a failed run was parsed out of the error message and printed as a
measurement — two numbers on that page came from it. It checks now.
bench/generate-awk.awk and bench/generate-solvm.awk are new; before them
nothing in the repository could reproduce the awk figures at all.
Tests: 176 → 189, of which 186 need nothing outside the repository.
2026-09-02 — awk, and five notation entries
A third language: languages/awk/. POSIX awk —
grammar, a call check, and a compiler to C. It is the first grammar here that
was not vendored from a specification, and the oracle carries the whole weight:
programs that e2fsprogs, ncurses and vim ship compile and print what
/usr/bin/awk prints.
Notation, all five argued for by a language that needed them:
%include |
a target language’s own includes, spliced by the reader (1.0) |
%embed |
a file’s bytes under a name, frozen at read time (1.5) |
$pos |
where a node came from (1.1) |
$pos as a span |
Position(line, column, file, endline, endcolumn) — a node is a stretch of source, not a point (1.4) |
%rewrite |
topdown, bottomup, innermost — replacing a node rather than decorating it (2.2) |
Settled by something already built:
1.3, a way
for a description to share a computation, closed with no new notation —
otherwise had shipped at stage 2, and languages/pascal/pascal.phx was
already using it twenty-one times with a comment saying why. The entry had
framed the problem as a map over a list; it was an attribute every node has.
Fixed: a literal holding a NUL was frozen with strlen, so -o wrote a
compiler that disagreed with phx for any description with a NUL in a literal
— which every binary backend has. The generated main also had no --raw.
Settled against: reference attributes, from JastAdd (2.1). awk needed a forward reference and two passes gave it.
semantics.md was made executable — the arithmetic specification is now checked rather than asserted.
2026-09-01 — stages 0 to 7, the tool itself
Eight tags in one day. Each is the end of a stage rather than a release.
| tag | what it delivered |
|---|---|
stage-0 |
read a grammar, scan and match a file, print the tree |
stage-1 |
-> actions: what a production builds, with no host-language splices |
stage-2 |
%pass, clauses keyed on the vocabulary the actions build, and otherwise. docs/semantics.md specifies the meta-language’s arithmetic in its own terms |
stage-3 |
%driver, passes that read each other’s work, %import, %require, and lib/expression.phx |
stage-4 |
actions on Wirth’s Pascal, taken from the published grammar unmodified |
stage-5 |
-o: a description written out as a C program that is its own compiler. Tables, not code — one implementation of the notation rather than two |
stage-6 |
Pascal that typechecks and compiles to C; fpc as an oracle, which found six bugs on its first run and four more on the next; one directory per language; the notation described in itself; a Solveig front end |
stage-7 |
a binary target — Solveig to .sob bytecode, held against solas byte for byte |
The oracle is the method, and it is worth stating once. Every language here
is checked against an existing implementation of it — fpc for Pascal,
/usr/bin/awk for awk, solas and solvm for Solveig — comparing what the
two produce rather than only what they print. The fpc oracle found ten bugs
in its first two runs, none of which reading the code had found.
Compatibility
None promised. The notation is still being decided; entries leave ROADMAP.md with a verdict, and some of those verdicts change how a description is written. Two changes so far would break an existing description:
$posbecame a span —Positiongainedendlineandendcolumn. Reading$pos.lineis unaffected, and both landed on 2026-09-02 hours apart, so only a description written between the two commits is affected at all.- A field that shadows a thread or a synthesised attribute is now an error. A description relying on the old silent behaviour will be refused, with a position.
The .sob format version is an equality rather than a floor: a SolVM build
reads exactly its own and refuses every other in both directions. Phoenix
writes 14, and tests/run.sh checks that against SOL_SOB_VERSION whenever a
Solveig checkout is to hand.