Phoenix

Phoenix  /  Roadmap

Roadmap

What is coming, what is borrowed, and what is deliberately absent. An entry here names why rather than when.

This page is what is not built. COMPLETED.md is the other half — the tool, the languages, the notation, and every entry that has left here with a verdict. journal.md records what each stage cost and what it got wrong first; postmortem.md scores the predictions; CHANGELOG.md answers the when this page deliberately does not.

Eight languages are described, and each that compiles is held against something that already did: Pascal against fpc, Solveig against solas, awk against /usr/bin/awk, C against cc, Z80 assembly against z80asm, and SolVM assembly against the bytes it assembled to last. Pascal’s units and calc, the worked example, make up the eight, and a ninth description, languages/phx/, is the notation written in itself.

Two entries were opened this way, and one is still open. Section 1 and section 2 stood empty from 2026-09-03, every stage and every borrowed idea having left with a verdict and three of them settled against building. What re-opened them was not a survey of what other tools have — that survey was done, and most of what it found is either already here or already refused. It was that two descriptions in this repository have a failure written down, in a divergent/ directory and in a changelog, and each names a thing the notation cannot say:

   
1.7 languages/solvm/ emits .sob and cannot read it
2.5 built 2026-09-23, for languages/z80/, as a driver stage run until an attribute settles. languages/units/ refuses A -> B -> C -> A, and orders initialisation as fpc does, since 2026-10-10

Neither is new. Both have been true for days and were recorded as costs rather than as work; what changed is lineage.md naming the prior art, so each is now somebody’s solved problem rather than an open question.

The rest of the page is section 3, what this project has decided not to have and why; section 4, what a description is checked for; section 5, the warts it knows about; and section 6, a C compiler — the one arc here that is a language rather than a mechanism, opened 2026-09-06 with its first step named and the rest deliberately not.


1. The stages

Eight have left, and one is open. COMPLETED.md has the eight, with what each predicted against what it cost. Three of the first seven predicted wrong, which is the more useful half, and one left settled against building it after four measurements. The eighth, 1.8, arrived and left on the same day, 2026-09-23, with its failure measured before any code: ROADMAP 6 said the predicate would be an entry here, and it was one for as long as it took to build. A ninth, 1.9, a computation shared across values, never stood here: it was built on 2026-10-10 from evidence in c.phx, the day the question was asked.

1.7 A repetition that counts

{ x } matches x until it stops matching. That is the only repetition the notation has, and it is the wrong one for a length-prefixed format, where what follows a count is that many of something and the thing after them is not an x that failed — it is the next field.

This is written down as a limit already, in the entry that shipped the assembler: Phoenix can emit .sob and cannot read it. The reading half is solvm --dump, which is another project’s binary doing a job this notation cannot ask for — and that makes the oracle for the one backend emitting bytes depend on a tool outside this repository.

What it would take is not obvious, and that is the entry. A count is a value a pass computes, and the grammar runs before any pass does. So either a repetition may read a field of the node being built — which makes the parser depend on the tree in a way nothing here does yet — or the notation grows a separate binary-reading half, which is a second mechanism and the thing 3.4 says to be afraid of.

Kaitai Struct has the vocabulary settled: repeat-expr for a computed count and repeat-until for a predicate, over a field already read. lineage.md has the family. Borrow the words before inventing any.

The condition for building it is a second format that wants it. One is a workaround; two is a mechanism. .sob is the one.

   
1.0 %include — a target language’s own imports
1.1 $pos — where a node came from
1.3 otherwise — what a node answers when its rule does not
1.4 a position is a span
1.5 %embed — a file’s bytes under a name
1.2 compiling the tables to code — settled against: measured four times, and the fourth found the control
1.6 \| as an expression operator — awk’s cmd \| getline
1.8 %names: names the parse keeps, for C’s typedef

Open: 1.7, a repetition whose count is a value the parse has just produced.

2. Borrowed, and worth borrowing

Each of these is somebody else’s solved problem. lineage.md says whose. All five are settled: three built, and two tested and refused.

   
2.1 reference attributes, from JastAdd — settled against: awk needed a forward reference and two passes gave it
2.2 strategies, from Stratego — %rewrite
2.3 scope graphs, from Statix — settled against: Pascal units were described to test it, and resolution stayed a list
2.4 inlining a block, from solas
2.5 circular attributes, from JastAdd: a driver runs a pass until an attribute of the root settles

3. What is deliberately not here

Four entries stand here, and two have left, both built. Each of the two was something this section said the notation would not have until a description made the case, and one did:

   
3.5 if c then a else b, built 2026-10-10 from the 1,072 tables c.phx wrote in its place
3.6 a down clause on a thread sets it for the subtree, built 2026-09-01 for a .sob method chunk

3.1 An interpreter that can loop

--run evaluates attributes, and an attribute is computed once per node in one walk. A loop needs its body evaluated a number of times that depends on the program, and a branch not taken must leave the variables alone. Neither is a thing a value computed once can say.

This is not a gap to fill. Interpreting is for checking a language while it is being designed; compiling is what Phoenix is for, and the clauses in languages/calc/calc.phx say so where a program runs into it.

It cost something, and the cost has been paid. The conformance rule is that one description, run two independent ways, gives one answer — and while one of the two ways was the interpreter, the rule covered straight-line programs only. Everything with a loop in it was checked by a single backend against a string somebody had typed into the suite.

calc-awk.phx is the second emit pass, so a looping program now has two implementations under it and the interpreter is not needed as the referee. What the target had to be is the part worth keeping: a second backend sharing C’s arithmetic would catch a mistake in its own clauses and not a host assumption leaking into the notation. awk’s numbers are doubles, so its / is floating division where calc’s truncates, and the backend writes the model out — semantics.md’s headline met a second time in a second host. It also needs nothing this repository does not already need, which is why it is awk and not the parked Solveig backend.

3.2 Actions as host-language fragments

yacc pastes C, Coco/R pastes C#, ANTLR pastes Java. It is the cheapest possible design and Phoenix will not have it, because a description containing host code can only ever generate that host — and what language should the generated compiler be written in stops being a question anybody can ask.

3.3 Guessing the lexical/syntactic seam

A rule is not lexical because of anything about its shape; identifier and expression look alike. A tool that guesses wrong reports a correct file as broken, which is the worst thing this one could do. %syntax is declared.

awk is what this costs, and it is worth being exact about whose problem it is. / is division and /re/ is a regular expression, and which one it is depends on the parser: a real awk lexer asks whether the previous token could end an expression. Phoenix’s scanner is longest match over the token rules and has no such feedback, so languages/awk/awk.phx guesses — a regexp may not start with a space, a tab or an =, and must close on the same line.

That is the description guessing, which is its business, and not the tool guessing, which is what this entry refuses. The difference matters: the guess is written down at the top of the file, it has a witness in tests/divergent/slash.awk, and rendering that program puts the spaces in so the divergence is visible rather than silent. A tool that guessed would have had nowhere to write any of that.

What a scanner with parser feedback would cost is the thing to weigh if this ever comes up twice: two languages have been described without wanting one, and the third wants it in one construct.

It came up twice, with C’s typedef, and the answer was not the scanner. The seam C has is not lexical: T is a name token either way, and what differs is which rule of the grammar it belongs to. So the feedback went into the parse rather than across the seam, as a table the parse keeps and one rule asks (1.8), and the scanner is still longest match with no opinion. awk’s / would want the other kind, and still does not have it.

3.4 A library that grows without deciding

phoenix/library.c is a separate file so that every addition is visible as an addition. A compiler generator whose library keeps growing has failed at something: the notation was not expressive enough and nobody noticed.

The rule an entry has to meet: a pass for a real language needed it, and it could not be written in the notation. quotient and remainder are the worked example: target languages disagree about negative division, and truncation could not be written in terms of flooring while the notation had no conditional.

The .sob backend added three, and each says what was missing:

   
bytes(n, width) a number as little-endian bytes; of a float, its IEEE 754 bits. A binary format cannot be emitted without it
int(text, base) Solveig writes #45, $ff and %1010 as one node, so the marker says the base
positions(list) the table saying where each thing is. A slot is a position in the frame’s list of names, and at wants an index rather than making one

The line table added one more, and one extension, and both say the same thing about the notation — see 1.3:

   
sizes(list) the size of each element. The companion to positions: that one answers where each thing is, this one how big it is, and neither can be asked any other way
bytes(list, width) a column of numbers, each as bytes. Not a new entry — the same function taking a list, the way bind takes names pairwise and each takes two lists

C’s constant expressions added three more, on 2026-09-25, for 6.5:

   
bitand(a, b), bitor(a, b), bitxor(a, b) the bits of two integers. A pass that works out case A \| B: before the program runs has nothing to write it with in + - * div mod

What was not added says as much. C’s other three bit operators, ~, << and >>, can be written in the notation: -x - 1, a multiply by a power of two, and a div by one, which floors as an arithmetic shift does. So they are not in the library, by this rule, and the C description will spell them out. The question this section asks of every entry was asked of these: whether they were one case of a shape nobody saw. The shape here is operators on integers the notation does not have, and the answer was three functions and not a fourth kind of operator, because the booleans already own the words.

Both are cases otherwise would have covered, and that is the entry worth reading beside this one. They were added while the question looked like “a map over a list”; it was 1.3’s, “an attribute every node has”, and a list of nodes then has a column of them for free. The rule this section states was met by both and they are still here, so the rule is not enough on its own: a library entry answers one case, and what it costs is the chance to see the shape of the rest.

C’s pointer levels added one more, on 2026-10-10:

   
repeat(x, n) text, or a list’s elements, n times over. Nothing else makes a thing of a computed length: each and . keep a list’s length and a template is fixed when it is read

c.phx wanted n pointer levels and wrote them as a thirteen-row table, 65 times, and as a twelve-copy string cut to length, 4 times: about 48 KB, a tenth of the file. And the table was a limit nobody had written down. It stopped at twelve stars, the thirteenth answered the table’s default, and a valid program lost every level of its pointer and was refused for a reason that was not true. The rule this section states was met, since n copies for any n could not be written; what the rule does not ask is what writing it the other way costs, and here that was a silent bound.

The same day, one more:

   
find(t, part) where part first begins in t, from one, or 0. Nothing else asks where, or even whether, one text is in another

c.phx asked whether by splitting on the word and counting the pieces, size(split($words, " static ")) > 1, 56 times, and asked where by splitting an alphabet on a character and measuring the first piece, 18 times, which is how a struct member’s character turned back into its number. All of it is find now, and every C program and refusal reads and compiles as before.


4. What a description is checked for

Every check Phoenix makes about a description exists because getting it wrong produces the same failure: something that looks right and is not. The three newest are all about when a clause runs, and all three were mistakes made more than once while writing languages/pascal/:

A fourth arrived with %include, and it is the same argument in a different place: a directive that names a node type and a field of it is two names that can be typos, and a typo in either is a mechanism that silently does nothing. Both are decidable from the description, so both are decided when it is read.

A fifth came with $pos, and it is about a name rather than about an order: pos means the same thing in every clause of every pass only if nothing else can be called that, so a field or an attribute of that name is refused. The cost of a reserved word is real and is the kind this project prefers to state rather than to hide — 5 already says so about and and or.

%rewrite brought three more, and all three are about a stage: a driver names one by its name, so two rewrites of one name, or a rewrite sharing a pass’s, is refused; and a rewrite reading .something a pass computes is refused, because a rewrite runs to change the tree rather than to answer about one and the walk it would be reading has not happened. The ordering hazard is checked in a rewrite exactly as it is in a pass, since both try their rules in order and the first match wins.

The first is a warning and the other two are errors, because the first is legal and merely almost never meant.

The general shape is worth stating: a description is read once and then run over every program ever compiled with it, so a fault found while reading it is found before anybody else sees it. That is the whole argument for a check existing at all, and the reason to prefer one over a comment.

And two things no check can reach. A description is checked when it is read; what -o writes out is checked by comparing it with phx, and that is only as strong as the widest thing compared. Text was compared for a year and the one backend emitting bytes was never written out as a compiler at all, so nothing noticed that a literal holding a NUL was frozen with strlen and arrived short while the length beside it still said otherwise. The two disagreed, silently, about a description they were both running. The suite compares .sob files now.

The second is the notation itself. Every check on this page is about a description; semantics.md is about the language descriptions are written in, and nothing asked whether that page and eval.c still agreed. They could have drifted a claim at a time with the whole suite green. Every claim it makes is a check now, and every refusal it names is a clause, run through phx and through a compiler phx wrote — which has to complain in the same words. A specification nothing runs is a document about a program.

And a third, found by asking what the round trip rests on. Three of these descriptions are checked by rendering the tree back and parsing it again, and the rule that makes it worth doing is stated in COMPLETED.md three times: a round trip can be green while the parse is consistently wrong, because what is written back out is wrong in the same way.

The renderers are hand-written — 122 lines in languages/awk/, 51 in solvm/, 46 in solveig/, 16 in calc/ — and a hand-written renderer is precisely a second chance to make the first mistake. Nothing checks that a show pass agrees with the grammar it is rendering; a person wrote both.

What closes it is known and is not on this page. Spoofax’s SDF3 derives the pretty-printer from the grammar, regenerated whenever the grammar changes, so there is no second artefact to disagree — the layout lives in the production that already says what the syntax is. It is not a roadmap entry because no description here is blocked by the absence, and this page’s bar is a language that cannot say something. It is written here instead, under the heading for things a check cannot reach, because that is what it is.

The three have one shape. Everything the suite checks, it checks by comparing two things — and each of these is a place where only one thing exists.

5. Known warts

pos is a reserved field name. Every node has a position and $pos is what reads it, in every clause of every pass — which only holds if nothing else can be called that. A grammar building a node with a field of that name is refused. One word, across the whole notation, and it is the same bargain as the one below rather than a different kind of cost.

A grammar module imposes reserved words. Importing expression.phx means and, or and not cannot be identifiers, because every word-shaped literal in the syntactic half is reserved. There is no way to import a grammar and decline its vocabulary.

There is no syntactic negative lookahead. ! is lexical only, refused in a syntactic rule because there it would ask about characters where there are only tokens. The cost is that the notation cannot describe two things the reader does: a production ending without its ., and a directive’s arguments ending at a line. The second was fixed by letting a directive be terminated; the first stands, and languages/phx/phoenix.phx records it.

Ordered choice is not revisited. a | b tries a, and if a succeeds and the rule around it fails later, b is never tried. It costs nothing on an LL(1) grammar, which is what nearly every published grammar is, and it costs a syntax error on a correct file otherwise.

Seven grammars later this has still not cost anything, including awk, whose grammar is famously not LL(1) — concatenation with no operator, and a print whose arguments exclude a rung the rest of the ladder has. Both were describable by putting the specific alternative first, which is what ordered choice asks for and what a published grammar written for yacc does not say.

And two of the seven now rely on it rather than merely surviving it. languages/solvm/ puts Label above the mnemonics because n:word ":" fails on push and falls through; languages/units/ tries slots 2 before slots self, n. Ordered choice asked for the specific alternative first in both, and in both that is also the clearer way to read the rule.

There is no iteration over data inside a pass. A %rewrite innermost reaches a fixpoint over the shape of a tree, and since 2026-09-23 a driver can run a whole pass until an attribute of the root settles, which is a fixpoint over a table at the granularity of a walk: 2.5. What is still true is the narrower thing: every list operation in the library answers in one step. each applies a template once per element, flatten opens one level, and none of them can be asked to run again on what it produced.

The cost was transitive closure, and languages/units/ was the first thing to want one. It has one since 2026-10-10: a pass that adds one step of reachability, run until the table settles, refuses A -> B -> C -> A. What it showed is the narrower thing above. The table holds one bit per unit rather than a list of names, because a list gains a duplicate every round on a cycle and never settles, and nothing in the library answers each element once. A bit set was available and is enough for 63 units. A second description needing a set of names that cannot be numbered would be the case for one; until then it is a thing nothing has made a case for.

A thread declared after the rules that assign it was silently not a thread, and is now refused. Kept here because the shape is worth having written down. Found on 2026-09-05, by languages/z80/’s seen table answering empty at every node. The clauses are classified as they are read — pass.c’s is_thread is consulted at that moment — so a rule written above the declaration gets an ordinary synthesised attribute, and a rule below it gets the thread. Two different attributes, one name, and no diagnostic:

%pass late                     %pass early
  Num  : seen = $seen + 1 .      thread seen = 0
  thread seen = 0                Num  : seen = $seen + 1 .
  Prog : total = $seen .         Prog : total = $seen .

The same three digits gave 0 on the left and 3 on the right. The comment in pass.c states the requirement — declared before the clauses that update it — so the reader was doing what it said it did; what was missing was the complaint when a description does not.

It is an error now, in check.c beside the shadowed thread it is a family with, and otherwise is covered as well as the rules. It went there rather than into pass.c, where the classification happens, because a pass is only whole once every %import has been read — a rule in one module and the thread in another is the same defect and would have slipped a check that ran per file. tests/grammars/thread-declared-late.phx is the witness.

A field can shadow an attribute handed down. A field is read before an attribute, so a down clause naming one of its own node’s fields hands a value to that node’s children which the node itself cannot read back. It is a warning rather than an error, because handing it down may be the whole point.

The same collision one node further down is warned about in a default. On 2026-10-10 languages/units/ handed a table down from its root as uses, and an otherwise clause read $uses at every node, three kinds of which have a field called uses. There the field won, the walk saw an empty list of what each unit uses, and the answer was wrong without a word. Since 2026-10-11 a default that reads $x, in a pass that hands x down, is warned about when it runs at a node type with a field x; tests/grammars/down-read-at-field.phx is the witness.

A clause in a rule for one node type is not asked, on evidence. Asked of everything, the check found twelve such reads in c.phx and every one was meant: a declaration hands its base down to its declarators, and a Cast inside one reads its own base field, a type name. A clause keyed on a type is written looking at that type’s fields; a default names no type, so it cannot mean the field at some nodes and the attribute at the rest.

The threaded version of the same collision is an error, and the difference is worth the two entries: a synthesised or inherited attribute that is shadowed is merely invisible somewhere, while a shadowed thread takes a value out of the fold and puts a different one back — the thread skips that node, every node after it carries on from a value that never went through, and nothing about the answer looks wrong. It was silent until a description met it, and the file that found it is tests/grammars/thread-shadowed.phx.

positions is zero-based, alone in a notation that counts from one everywhere else — text indices, at, a reported line and column. It is that way because what it exists for is slot numbers, and a frame’s first slot is zero. The inconsistency is real and is kept on purpose: making it one-based would mean every use of it subtracting one, which is the off-by-one this notation is otherwise arranged to avoid.

A parse reports one syntax error, and only one. A pass collects its complaints and reports all of them — three undefined names give three error: lines — but a parse stops at the first failure. Reporting more than one needs error recovery, which changes what a parse is; lineage.md has the literature, and nothing here is blocked by having one.

Where that one error is reported was a second wart and is now fixed. It is worth leaving the shape of it written down. parse.c tracks the position the match got furthest, which is the right heuristic; there were two failure paths and the second discarded it, overwriting furthest with the first leftover token while keeping the wanted list recorded elsewhere. Because a start rule that is a repetition can never fail outright — program = { statement } succeeds on zero statements — every syntax error in every language here took that path, and named a token that was usually legal where it stood.

A test was pinning it. languages/awk/tests/divergent/spaced-regex.awk asserted the message contained and found "BEGIN" — the parser blaming the first token of the file for a fault 34 columns into line 13. It now asserts the position, which is what the divergence is about.

A description may guess where the tool will not. languages/awk/awk.phx decides whether / opens a regexp by looking at the character after it, because awk’s own lexer asks the parser and Phoenix’s scanner cannot — see 3.3. That is the description’s business rather than the tool’s, and the difference is that a description can write the guess down, test the shapes it gets wrong, and render them back visibly. languages/awk/tests/divergent/ holds all three.


6. A C compiler

On hold since 2026-10-06, by Hans’s decision. The workspace’s C compiler will not be the one Phoenix generates. It is to be written by hand in C, in ~/Projects/DevTools, the project for the Ouroboros development tools, beside the preprocessor and the editor. So the arc below stops where it stands: no entry is opened, and the next stop it names, a ?: between two structs at line 971 of pp.c, is not lifted. Nothing is taken out. languages/c/ stays in the tree and in make test, as one more description that proves the notation, and the map below stays as the record of where the compiler stopped and what a resumed arc would find first. The Proem probe is no longer run against each fix. 6.11 is on hold with the rest, since its reason was a Phoenix-built cc running on Ouroboros.

What replaces the destination is Phoenix itself on Ouroboros, ported whole at some point in the future: the tool, its descriptions, and the compilers it writes out, which are one-file C11 and would build there with whatever C compiler Ouroboros has by then. That is not an entry yet and has no date. It waits on that compiler, which is DevTools’s, and on the C runtime Ouroboros is building in its docs/roadmap/roadmap-c-hosting.md. Both were told, each with a notice named docs/handoffs/2026-10-06-from-phoenix-c-compiler-on-hold.md in its own tree. Neither asks for anything, so nothing here waits on a reply.

Why this page has a language on it. Every other entry here is a mechanism the notation lacks, and a language arrives in languages/ to test one. This entry is the other way round: the language is the goal and the mechanisms it turns out to need are the findings. The goal is the workspace’s, not only this repository’s — ../../DevTools/docs/c-compiler-toolchain.md records the intent that every tool of a C toolchain eventually exist in the workspace, says what the stages are, and says why Phoenix is the one to start from: its passes with thread, down and environments already carried a Pascal typechecker, and the three things a C compiler needs that no tool here has — a preprocessor, typedef feedback into the parse, a back end that reaches a machine — are outside the grammar, not a stronger grammar. That document has the order of the whole arc, and this page copies none of it. Its first step, 6.1, is complete, closed on 2026-09-23 when long made its last two divergences agree. Its second, 6.2, a function that returns a pointer, was opened and closed on 2026-09-25. Its third, 6.3, the rest of C’s expression operators, was opened and closed the same day. So were 6.4, the statements, and 6.5, expressions worked out before the program runs, built in the order 6.4 without switch, then 6.5, then switch. What keeps a C program out of the subset now is its types and its declarations, step four of the toolchain document, and each arrives the way every entry does, with what breaks without it written down first. The first of them, 6.6, a call to a function declared with ..., was opened and closed on 2026-09-26. So was the second, 6.7, void and casts, and the third, 6.8, a variable declared outside every function. So was the fourth, 6.9, the unsigned types, opened on 2026-09-26 and closed on 2026-09-27, the first entry in the arc with a program the subset compiled and got wrong. So was the fifth, 6.10, const and static, opened and closed on 2026-09-29, chosen by what CPP’s own source uses most that the subset did not have. 6.11, a second target, was opened the same day and parked the same day, when the arc was put before the port. So was the sixth, 6.12, declarations as C11 6.7 has them, opened on 2026-09-29 and closed on 2026-09-30, the first entry of the C11 arc. So was the seventh, 6.13, initialisers as C11 6.7.9 has them, opened on 2026-09-30 and closed on 2026-10-01, the second. So was the eighth, 6.14, pointers to functions, opened and closed on 2026-10-01, the third. So was the ninth, 6.15, the integer types, opened and closed on 2026-10-01, the fourth. So was the tenth, 6.16, escapes and joined strings, opened and closed on 2026-10-02, the fifth. Nothing in the arc is open now; the next entry is chosen from the map below.

The arc has a destination since 2026-09-29: C written on Ouroboros. The workspace chose it, and ../../DevTools/docs/c-compiler-toolchain.md has the order, whose last step is the one 6.10 was already steering by: the chain compiling CPP’s own source, there. CPP is now Proem, since 2026-10-01: the workspace’s preprocessor was renamed, its folder is ~/Projects/Proem and its repository github.com/hansolovkarlsson/Proem, and ~/Projects/CPP no longer exists. Every mention of CPP on this page means Proem. Since 2026-10-06 Proem’s source is in DevTools, ~/Projects/DevTools/proem/, and ~/Projects/Proem is archived as ~/Projects/archive/Proem. Later that day it took back the name cpp, in ~/Projects/DevTools/cpp/, its driver driver/cpp.c; the probe keeps its directory, tests/proem/, and the name Proem below is the one it had when the counts were made. What it asks of this page, once the C compiler is complete, is a second target for c-arm64.phx. Every choice the back end makes today is Apple’s: _main, @PAGE fixups, variadic arguments on the stack, a signed plain char. Ouroboros is ELF under AAPCS64 as written, so a program calling printf compiled for it would hand picolibc its arguments where picolibc does not look. The oracle for that target is clang with --target=aarch64-unknown-none. It is written down as 6.11, with its failing program first, and parked until then.

What Phoenix cannot say today, so that the step is honest about what it avoids.

   
x * y; a declaration if x is a typedef, a product otherwise. The scanner cannot ask the parser and the parser cannot ask a pass, so the parse cannot know. 3.3 refused scanner feedback with the words if this ever comes up twice; awk was the first, and C’s typedef would be the second, with the difference that awk’s guess is lexical and C’s is a scope the parse itself is building. A semantic predicate on the identifier rule is the PEG answer, and it is a change to the tool. Answered 2026-09-23 by %names, 1.8
#include, macros, #if a language on the token stream, expanded and rescanned. Not a grammar and not a tree walk, so no place for it in a description. cc -E supplies it until the workspace has its own. The workspace has one since 2026-09-27, ~/Projects/DevTools/cpp (CPP until 2026-10-01, then Proem, in ~/Projects/Proem and DevTools/proem, until 2026-10-06), not yet put in cc -E’s place
a machine every backend here emits C, an outline, or .sob bytes. None emits an instruction sequence for a real processor; languages/solvm/ and languages/z80/ show that labels and an order the input never mentions are within reach of an emit pass

The arc now: C11, before Ouroboros. Decided by Hans on 2026-09-29, after 6.11 was opened: the first arc is c.phx conforming to C11, and the port to Ouroboros is a later arc, so 6.11 is parked where it stands. The C compiler is completed before the port begins, and Hans made the rule exact on 2026-09-30: nothing whose only purpose is Ouroboros starts until he is satisfied with the compiler itself. That is 6.11, and after it cross-compiling onto Ouroboros, an assembler and a linker there, and anything else that exists for the port alone. The gate is his judgement of the compiler, not the close of any one entry: conforming to C11 is the arc that is to get it there. What conforming means was settled on 2026-09-29:

What is left between the subset and C11. Counted on 2026-09-29 from c.phx, its refusals in languages/c/tests/refused/, CPP’s own source, and two dozen programs tried against the check driver. It is a map of the ground and not a queue: none of it is an entry until it is opened with its failing program. It is a careful count and not a clause-by-clause audit, so a construct missing from it is not proof that the subset has it; each entry audits its own clause of the standard when it opens. Bold marks what compiling CPP’s source needs, which still says where the arc is going after this one.

  what the subset lacks
declarations, 6.7 most of it since 6.12. Function pointers since 6.14. Left: a name in scope in its own initialiser, C11 6.2.1p7, in a plain local since 2026-10-02, and in braces, a static and a global since 2026-10-04, and in a declarator with a * in parentheses since 2026-10-05, and refused by name in an array in braces; a function declared through a typedef of its type, binop add;, at file scope since 2026-10-04, and in a block and as a parameter, which is a pointer to one, since later that day; a * in parentheses inside another’s, int (*(*p)), since 2026-10-05, where parentheses around a name alone have been the name since 2026-10-04, int (x) = 3;, and where one with brackets or a function inside, int (*(*p)[3]), is still a syntax error; a function declarator in a list, int x, f(void);, at file scope since 2026-10-04, and in a block since later that day; a function declared in a block where a local of its name is in scope, and a function declared in a block known only to the block’s end, as C11 6.2.1p4 has it, since 2026-10-05; an identifier-list definition whose declarations are in another order or name two at once; a struct passed whole to a prototype written before it is complete; a global of a struct completed later in the file
initialisers, 6.7.9 all of it since 6.13, but for what waits on other rows: a union’s and a wide string; an enum constant as a value came with 6.15. Refused by name: an array designator that is an expression and not a number written out; an object nested more than eight levels deep; a struct over 32767 bytes or 86 members, in an initialiser; a struct’s compound literal initialising a global, which cc takes as an extension. Refused where a constant is wanted, which cc takes as an extension and C11 6.6p6 and p8 do not: a pointer cast to an integer, and !, &&, || or the condition of ?: given a pointer, since an integer cast to a pointer became an address constant on 2026-10-04; and a ?: choosing between pointers in a global’s initialiser
types, 6.2.5 the integer types since 6.15. Left: union, float, double and long double, bit-fields, an enumerator’s value from unsigned arithmetic where C’s answer is not the exact one, -1 / 2u, refused by name since 2026-10-04, and one from a cast or a sizeof, and an array’s size from a constant expression, int t[BLUE]
qualifiers, storage and specifiers volatile, restrict, inline, _Noreturn, _Alignas and _Alignof, _Thread_local, each reserved and refused by name; a qualifier after a * other than const
functions *a variadic function defined, through cc’s builtins for , since 2026-10-04*; a ninth parameter or a struct needing a ninth register, a struct passed through `...` or to a function with no prototype; *`()` as no prototype in a declaration, and since the same day in a pointer to a function, a typedef, a cast and a parameter*, where a definition's is still no parameters
lexical, 6.4 the escapes, octal and hex escapes and joined strings since 6.16. Left: the prefixes L, u, U and u8 on a string or a character constant, universal character names, floating constants, a character constant of more than one character; refused by name, a hex escape of more than two digits whose others are zeros
expressions _Generic. ?: between two pointers takes the const levels of both since 2026-10-04, sixteen positions written out, since the notation has no loop
refused by name, by choice the four optional features above; an address with an offset in a global’s or a static local’s initialiser, until one folds

The declarations row came first, because most of the rest is written in it, and it was 6.12. What it left behind is the two rows CPP’s source needs most: initialisers in braces, which its five static arrays want and which an array whose size its initialiser gives is part of, and function pointers, which its diagnostic callback wants and which 6.12 parses and refuses. Either is the next entry, chosen as the others were, by what breaks without it: initialisers were, as 6.13, because CPP’s source writes thirty and function pointers need them too. Function pointers are what is left of the two, and the one place an initialiser in CPP’s source still stops. They were the next entry, 6.14, opened and closed on 2026-10-01. What Proem’s source wants next is the types row: _Bool 113 times, then enum, union and long long. Counted again on 2026-10-01 in ~/Projects/Proem, lib/ and driver/: bool 143 times, enum 9, long long 4, and no union, short or floating point; the integer types were the next entry, 6.15, closed the same day. What Proem’s source stops at after them is the lexical row: '\r', '\v' and '\f' in source.c and lexer.c, and a message written as two literals side by side in every file that reports one. That was the next entry, 6.16, opened and closed on 2026-10-02.

What Proem’s source stops at was run, not counted, on 2026-10-02. Each file in lib/ and driver/ was put through cc -E and the check driver. With Apple’s headers, every file stops in them before a line of Proem: two at long double max_align_t in <stddef.h>, eight at the typedef union of Darwin’s __mbstate_t, which every program including <stdio.h> meets and which is a question of its own. With stub headers declaring only what Proem uses, in the subset, and cc -pedantic clean on all ten, two files are accepted whole, diag.c and token.c, and the other eight stop at four things, each the first in its file:

construct files it stops times Proem writes it
a name in its own initialiser, proem_source *s = calloc(1, sizeof *s); expand.c, macro.c, source.c, driver/proem.c 13
a variadic function defined, va_start expr.c, pp.c 4
a function returning a char lexer.c 1
?: with a null pointer constant, c ? "2" : NULL output.c 7

The first is a defect and not a missing feature: C11 6.2.1p7 puts a name in scope from the end of its declarator, and the locals pass binds it at the end of the declaration. COMPLETED has said so since 2026-09-23 and refused/local-in-its-own-initialiser.c holds it, but it was on no list here until the probe found it the commonest stop. It was fixed the same day, for a plain local, which is every case Proem writes; the other forms are refused by name.

Run again with the fix in, the same day, with the same stubs: five files are accepted whole, diag.c, token.c, expand.c, macro.c and driver/proem.c, and the other five stop at three things. ?: with a null pointer constant stops two, output.c at c ? "2" : NULL and source.c at has_id ? &id : NULL, which C11 6.5.15p6 gives the other arm’s type; defining a variadic function stops expr.c and pp.c; and a function returning an unsigned char stops lexer.c. A probe finds only the first stop in each file, so what is behind each is found when it is lifted.

?: with a null pointer constant was lifted on 2026-10-03, and a third run, with the stubs written again, accepts output.c and source.c whole: seven of the ten. Nothing was behind it in either. expr.c and pp.c stop at va_list, the typedef a variadic definition declares before its va_start, and lexer.c at the char return. The char return was lifted the same day, and lexer.c stops 364 lines further on, at len <= sizeof small ? small : malloc(len): ?: with a pointer to an object on one side and a void * on the other, which C11 6.5.15p6 makes a void *, and which is refused here as pointers to two different types. That was lifted the same day too, and lexer.c is accepted whole: eight of the ten. What stops the other two, expr.c and pp.c, is va_list, a variadic function defined. The stubs and the probe are in the repository since that evening, languages/c/tests/proem/: run.sh prints each file’s first stop against a Proem checkout, by default DevTools’s proem/ beside this repository (../Proem until 2026-10-06), after checking it against the stubs with cc -pedantic. It is not in make test, since Proem is not here. Twice the stubs had been written in a job’s scratch and were not there for the next run.

A variadic function defined was lifted on 2026-10-04, through the four builtins 's macros are in `cc`'s headers, and `expr.c` is accepted whole: **nine of the ten**. `pp.c` stops 644 lines further on, at line 971 of what `cc -E` makes of it, `file_key k = {id != NULL, id ? *id : (proem_file_id){0, 0}, path};`: **a `?:` between two structs as a member's value in braces**, refused as a struct where a number or a pointer is wanted. That is the next entry.

Outside the grammar, the rest of the chain belongs to the later arc, which waits for the compiler as above, and the toolchain document has its order: the second target (6.11, parked), CPP put in cc -E’s place, and an assembler and a linker that run on Ouroboros, both DevTools’s, written by hand in C. Not Futamura, since 2026-10-06: the assembler had waited on whether Futamura could describe how an arm64 instruction is encoded, and Hans settled that Futamura is not part of the chain. Past correctness, the lcc route (an IR, instruction selection and a register allocator in place of the stack machine) is the toolchain document’s last step and belongs to it.

6.11 A second target: ELF under AAPCS64

Parked on 2026-09-29, the day it was opened: the C compiler is completed first, and this entry is the first step of the arc after it, which starts only when Hans is satisfied with the compiler itself. Nothing below has been built, and the oracle was tried by hand only. On hold since 2026-10-06 with the rest of section 6: the C compiler for Ouroboros is DevTools’s now, and this entry waits for the arc to resume, if it does.

The destination is C written on Ouroboros, and the first step of its order is this one: the back end writes for Ouroboros as well as for the Mac, checked against clang under QEMU. The toolchain document put it before any construct, because every construct added before it is one more thing done twice, once for each target. The C11 arc was put first anyway, knowing that cost: every construct it adds is one more this entry must carry to the second target. %driver arm64 stays what it is, and cc stays its oracle.

What writing the witness found first: Phoenix’s output is refused by the ELF assembler, and once that is fixed by hand, it runs and is wrong. One program, calling printf with three numbers, the last of them a comparison on a char holding 200:

program clang for aarch64-unknown-none, under QEMU Phoenix today
char c = 200; int main(void) { printf("%d %d %d\n", 1, 2, c > 0); return 0; } prints 1 2 1, exits 0 clang --target=aarch64-unknown-none -c refuses it at six places: @PAGE twice, @PAGEOFF twice, and the Mach-O names of two sections
the same .s with only those spellings rewritten by hand, _main to main, L to .L   assembles, links, runs, and prints 0 0 0

The second row is the one that matters, because nothing refuses it. It is two wrongs, and either one alone would print a wrong line. The variadic arguments are on the stack, where Apple’s variant puts them, and picolibc reads them from x1 to x3, where AAPCS64 as written does. The char is loaded with ldrsb, so 200 is -56 and c > 0 is 0; plain char is unsigned under AAPCS64, and clang loads it with ldrb.

The target reaches c.phx, which says it has nothing about any target. Two facts in the language file are Apple’s: the constants pass folds a cast to char by putting the sign back, so (char)200 is -56 in a case label, and signed char is read as a plain char because a plain char is signed on this machine. Under ELF those are two types and the fold keeps 200. Only the signedness of char crosses; the rest of the difference is spelling and the calling convention, which are the emit pass’s.

How the oracle runs, tried by hand on 2026-09-29. Nothing in the Makefile of Ouroboros is needed, and the OS is not booted. A program is compiled by clang with --target=aarch64-unknown-none -mstrict-align, or by Phoenix and assembled by clang, and linked by the rust-lld that rustup installs, against picolibc 1.8.9 as Ouroboros prebuilt it, compiler-rt’s two 128-bit shifts from Ouroboros’s libc/pico/builtins.c, and a harness of three short files: a start that sets the stack, turns on the floating point registers printf touches, and calls main and then _exit; a port that puts stdout on the PL011 UART of QEMU’s virt machine and makes _exit a semihosting SYS_EXIT_EXTENDED, so the status reaches the shell; and a link script at 0x40080000. qemu-system-aarch64 -M virt -semihosting -kernel runs it. clang’s program above printed 1 2 1, and one returning 7 exited 7. It is an optional oracle, skipped and not failed without QEMU, rust-lld or picolibc, as fpc, solas and z80asm are, so the suite still needs nothing outside this repository. picolibc is read where Ouroboros keeps it and never written there.

How much it touches. Every one of the oracle’s 266 programs has a main, so every one changes. 30 of them take an address with @PAGE today, 20 call printf, and 78 have a char.

Built in three parts, in this order, each with the suite green:

  1. The harness and the spellings. The oracle’s runner learns a second target, and the emit pass writes, for it, names without the underscore, local labels with .L, adrp with no suffix and add with :lo12:, and ELF’s section names. How a description says which target is the first thing this part settles: phx has no option that reaches a pass, so the target is chosen by a driver, %driver elf beside %driver arm64, and whether that driver names a small pass that the emit pass reads, or a second emit pass, is decided with the witnesses in hand. The witnesses are every oracle program without a printf or a char, which should agree once the spellings do.
  2. Variadic arguments in registers, AAPCS64 as written: a call to a function declared with ... passes its variadic arguments as it passes the named ones, in x0 to x7 and then on the stack. The twenty printf programs are the witnesses, the first of which is the one above with its char taken out.
  3. An unsigned plain char: ldrb and not ldrsb, and and not sxtb when a value is narrowed, a fold in c.phx that keeps 200, and signed char a type of its own. The 78 char programs are the witnesses, and the one above, whole.

Not in this step: cross-compiling anything onto Ouroboros, an assembler or a linker there, which are the order’s next steps and other projects’ work; .type and .size, which the link does not need; and a third target. The entry closes when every program in the oracle agrees with clang under QEMU as it agrees with cc on the Mac, with none diverging on either.