Skip to content

fix(c): forward-declare structs so a self-referencing one compiles (+4, 0 regressions) - #2949

Merged
gHashTag merged 8 commits into
masterfrom
w790
Aug 30, 2026
Merged

fix(c): forward-declare structs so a self-referencing one compiles (+4, 0 regressions)#2949
gHashTag merged 8 commits into
masterfrom
w790

Conversation

@gHashTag

Copy link
Copy Markdown
Owner

The defect

C requires a type's name before any use of it. The C backend emitted every struct
as an anonymous typedef:

typedef struct {
    BTreeNode** children;      /* BTreeNode is not in scope */
    K* keys;
} BTreeNode;                   /* ...until this line closes */

A struct that names itself is a cycle of length one. The topological sort
landed in #2941 orders declarations by dependency, and no ordering can put a
declaration before itself — the sort is powerless here by construction, not by
oversight.

Measured: 24 corpus specs write a self-referencing struct.

The fix

Emit a forward-declaration block, then tagged bodies:

typedef struct BTreeNode BTreeNode;   /* names the tag */

struct BTreeNode { ... };             /* tagged, so the name above refers to it */

An anonymous typedef struct { ... } Name; cannot be forward-declared at all —
there is no tag to declare — so the body had to become tagged for the forward
declaration to mean anything. The two changes are one change.

The topological sort stays: a forward declaration gives C the name, which is
enough for a pointer member and not enough for a by-value member. The two
mechanisms cover different halves of the same requirement, and a mutant that
disables the sort is still killed by its own tests (M4 below).

Measurement

Per spec, both directions, on an idle machine, 650 corpus specs:

before after
cc -fsyntax-only -std=gnu11 accepts 264 268
  • newly accepted: 4encoding/markup, trees/b_tree, trees/kd_tree, trees/rtree
  • regressions: 0 (empty set, computed as before - after)

All four are tree structures — the family the defect was found in. This is a
small number against 24 carrying specs, and the honest reading is that the other
20 are blocked further down; b_tree itself still fails on BTreeNode(K, V)* children — a generic-parameterised type name reaching C, which has no
generics. That is a separate defect and it is not fixed here.

Mutation testing

Four mutants, each killed by the test that should catch it:

mutant killed by
M1 drop the forward-declaration block a_self_referencing_struct_is_forward_declared
M2 restore the anonymous typedef body 8 of 9 tests
M3 forward-declare only self-referencing structs every_struct_is_forward_declared_not_only_the_recursive_ones
M4 (control) disable the topological sort a_struct_used_before_it_is_declared_is_emitted_first

M3 is the interesting one: declaring only the recursive structs leaves a mutual
pointer cycle (L names R, R names L) with no name in scope for either.

A mutant the freeze rejects is not a measurement

The first three mutation runs printed nothing and I nearly read that as "killed".
They were rejected by the M5 freeze build scriptbootstrap/stage0/FROZEN_HASH
no longer matched — so cargo test never built, never ran, and produced neither a
test result line nor a plain error: line. Absence of "ok" is not death.
Mutating compiler.rs requires resealing FROZEN_HASH per mutant, and the verdict
has to separate three outcomes, not two.

A flaky test found on the way

bootstrap/tests/struct_order_c.rs keyed its temp directory by
(pid, src.len()) and each test remove_dir_alls it. Two tests whose sources
happen to be the same length shared one directory; under the default parallel
runner one erased the spec another was mid-read of, t27c printed nothing, and
the assertion read got []. It passed the first time it ran. Now keyed by an
atomic counter; 3 consecutive runs green.

Seals

tri seals drift is 0 on master and 356 with this change — the C output
shape changed for every spec containing a struct, which is exactly what this
change does. 334 seal files rewritten in the same commit as the emitter and the
FROZEN_HASH reseal, so no commit in this branch leaves the tree inconsistent.


Closes #2948 · Refs #2941

🤖 Generated with Claude Code

C requires a type's name before its use. Every struct was emitted as an
anonymous typedef, so a struct naming itself had no name in scope until
the line that closed it:

    typedef struct {
        BTreeNode** children;   <- not in scope
    } BTreeNode;                <- until here

That is a cycle of length one. The topological sort landed in #2941 cannot
order a declaration before itself -- powerless by construction, not by
oversight. Measured: 24 corpus specs write one.

Emit a forward-declaration block, then tagged bodies. An anonymous typedef
cannot be forward-declared at all, so the body had to become tagged for the
declaration to refer to anything; the two edits are one change.

The sort stays. A forward declaration gives C the NAME, enough for a pointer
member and not enough for a by-value member, so the two mechanisms cover
different halves of the same requirement. Mutant M4 disables the sort and is
still killed.

Measured per spec, both directions, idle machine, 650 specs:

    cc -fsyntax-only -std=gnu11 accepts   264 -> 268
    newly accepted 4   markup, b_tree, kd_tree, rtree
    regressions    0   (empty set)

Four of 24 carrying specs, and the honest reading is that the other 20 are
blocked further down: b_tree still fails on `BTreeNode(K, V)* children`, a
generic-parameterised type name reaching a language with no generics. That
is a separate defect and is not fixed here.

Mutation testing, four mutants, each killed by the test that should catch it:
drop the forward block; restore the anonymous body; declare only recursive
structs (leaves a mutual L/R pointer cycle unnamed); disable the sort.

Two rulers were broken on the way and are fixed here:

  A mutant the freeze rejects is not a measurement. The first three runs
  printed nothing because the M5 build script refused a compiler.rs whose
  hash no longer matched FROZEN_HASH -- no build, no test, and neither a
  `test result` line nor an `error:` line. Absence of "ok" is not death.

  The test helper keyed its temp directory by (pid, src.len()) and each test
  removed the whole directory. Two tests with equal-length sources shared it;
  under the parallel runner one erased the spec another was mid-read of and
  the assertion read `got []`. It passed the first time it ran. Keyed by an
  atomic counter now; three consecutive runs green.

Seals: drift is 0 on master and 356 with this change -- the C shape changed
for every spec with a struct, which is what this change does. The 334 seal
files, FROZEN_HASH and the emitter are resealed in this one commit, so no
commit in this branch leaves the tree inconsistent.

Closes #2948
Refs #2941

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-30 14:16:39 UTC

Summary

Status Count
Total Open PRs 8
PRs with Failing Checks 7
PRs with All Checks Green 1
READY 0
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=10e954ca0693 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

Refs #2948

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-30 14:54:16 UTC

Summary

Status Count
Total Open PRs 10
PRs with Failing Checks 7
PRs with All Checks Green 3
READY 0
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=10e954ca0693 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

`_core_c` sliced the struct out of the generated C with a regex keyed to
the anonymous shape:

    typedef struct { ... } TernaryWeight;

The emitter now writes a forward declaration and a tagged body, so the
regex matched nothing, `_core_c` returned None, and all three C arms
reported "C backend failed to build/run" -- which reads like an
arithmetic disagreement and is not one.

Both shapes are accepted now, so the tool judges the arithmetic rather
than the spelling and keeps working on either side of the change. The
exhaustive FNV-1a digest is unchanged at 6b2724c5 over all 65,536 inputs:
the struct change is spelling, and this is the measurement that says so.

Two things this cost, both worth writing down:

  emit-bitexact does not run on master (`pull_request` with a `paths:`
  filter and no `push:`), so there was no baseline to compare against.
  The baseline came from OTHER branches instead -- green on w791, w792,
  and three other agents' branches, red only on this one, which is what
  convicted this change.

  Locally the tool passed on this branch, twice. Both readings came from
  a t27c built before the emitter change: `git checkout` does not rebuild.
  The reader was keyed to the old shape the whole time.

The sibling reader at line 441 slices the HOISTED tuple typedef, which is
emitted by a different path and is genuinely still anonymous -- checked
against the generated output rather than assumed.

Refs #2948

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-30 14:59:35 UTC

Summary

Status Count
Total Open PRs 10
PRs with Failing Checks 7
PRs with All Checks Green 3
READY 0
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=10e954ca0693 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

382 a mutant the M5 freeze rejects is not a measurement -- three arms,
    not two, because a build refusal prints neither a test result nor an
    error line
383 a shape change breaks every reader keyed to the old shape; the
    dangerous one goes vacuously green, not red
384 a gate that never runs on master has no baseline -- borrow one from
    sibling branches rather than reading 'no data' as 'no problem'
385 git checkout does not rebuild; the stale binary is usually the old
    one, so a change measures as harmless exactly when it is not
386 a race that has not fired is not a test that passed -- 12 green runs
    while the collision happened on every one; probe the identity
387 two rulers that disagree: report both, name which the ratchet uses,
    and measure the inflation ceiling on purpose
388 fix the emitter once, then count its call sites; a structural test
    when the second path needs a repository on disk
389 a detector needs a counterexample, not a review -- two holes, both
    invisible by reading the rule, both frozen into --self-check

Refs #2948
Refs #2951
Refs #2954

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gHashTag

Copy link
Copy Markdown
Owner Author

This branch also carries the skill update for all three of today's changes (.claude/skills/ci-gates/SKILL.md, sections 382-389), because the git checkout that was meant to move it to #2955 was refused by an uncommitted file and the commit landed here. Rewriting history to move it would need a force-push, so it stays.

Sections 383, 384 and 385 are this PR's own lessons: a shape change breaks every reader keyed to the old shape (one of them went vacuously green, not red); a gate that never runs on master has no baseline, so the baseline came from sibling branches; and git checkout does not rebuild, which is why this passed locally twice while CI was red.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-30 15:03:12 UTC

Summary

Status Count
Total Open PRs 10
PRs with Failing Checks 7
PRs with All Checks Green 3
READY 0
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=10e954ca0693 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

gHashTag added a commit that referenced this pull request Aug 30, 2026
…the class (#2955)

* fix(tests): give each test its own scratch directory, and a gate for the class

Four test binaries computed their scratch directory from a key every test
in the binary shares, and each test deletes the WHOLE directory on the way
out:

    let dir = temp_dir().join(format!("t27-scaffold-{}-{}",
                                      process::id(), src.len()));
    ...
    let _ = std::fs::remove_dir_all(&dir);

One process per binary, so the pid is constant and the key is really
`src.len()`. Two tests whose sources are the same length get one directory,
and under the parallel runner one erases the spec another is mid-read of:
t27c prints nothing and the assertion reports an empty result.
verilog_imported_enum interpolates only the pid, so all three of its tests
shared one directory.

Measured rather than inferred. Printing the paths of one scaffold_c run:
6 tests produced THREE directories before, six after. A probe asserting
the directory was fresh fired 8 runs out of 8 on const_width,
pointer_param_c and verilog_imported_enum -- the collision happens every
run and only the timing of the delete decides whether a test dies.
scaffold_c failed about one run in three; the others had not fired yet.
It passed the first time it was written. A green run does not clear a race.

Keyed by an AtomicUsize counter now: unique per call, which is what a
directory its own user deletes requires.

struct_order_c is the fifth carrier and is fixed in #2949; touching it in
both branches would only make a conflict.

`tri harness scratch` (--gate, --self-check) keeps the class from coming
back. It looks for the conjunction -- more than one #[test], a scratch path
under temp_dir(), a remove_dir_all of it, and a key with no per-call
component -- because any one alone is fine. Against master it reports
exactly the five the probe convicted and nothing else.

Two false readings it had to survive, both found by counterexample:

  Its first rule asked whether the format! call contained a `{`, true of
  every single-line format call, so a pid-only key looked variable and it
  reported nothing on verilog_imported_enum while the probe fired 8 of 8.
  It judges the arguments now.

  Judging only the arguments then convicted backend_behaviour, whose key is
  format!("...-{tag}") -- an inline capture, distinct per test, with no
  argument list at all. Inline captures are read as arguments now.

Grepping the symptom rather than the construct also convicts
verilog_r_si_1, where src.len() sizes a String and never touches a path.

The self-check is five-way: a planted collision and a pid-only key must be
seen; counter-keyed, inline-capture and single-test files must not be
flagged. A failing leg exits non-zero and says the clean run claims nothing.

--gate is deliberately NOT wired into CI here: it stays red until #2949
removes the fifth carrier. Wiring it belongs in the commit after both.

Closes #2954
Refs #2949

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(now): entry for the scratch-directory race

Refs #2954

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@gHashTag
gHashTag enabled auto-merge (squash) August 30, 2026 15:13
@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-30 15:13:20 UTC

Summary

Status Count
Total Open PRs 9
PRs with Failing Checks 7
PRs with All Checks Green 2
READY 0
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=10e954ca0693 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

gHashTag and others added 2 commits August 30, 2026 22:18
# Conflicts:
#	.claude/skills/ci-gates/SKILL.md
Three conflicts, and each one is resolved by regenerating rather than by
choosing a side:

  FROZEN_HASH -- recomputed from the merged compiler.rs, which now carries
  both this branch's forward declarations and master's Zig prelude.

  338 seal files -- the merged emitter's output is what they must describe,
  so they are recomputed. `tri seals drift` reads 0 across three runs with
  a binary the tool itself reports FRESH; the first reading of 338 was
  taken before the rebuild finished and is not the measurement.

  .claude/skills/ci-gates/SKILL.md -- master had taken 382-386 for another
  agent's sections while this branch used 382-389. Master's numbering is
  kept EXACTLY as it stands and mine are appended as 387-394. The last time
  a skill conflict was resolved here the other side was renumbered, which
  broke every cross-reference into it; the rule is that the side already on
  master does not move.

  The commit messages and PR body on this branch still say 382-389. They
  describe the sections by title, and the titles did not change.

`tri harness scratch` now reports NONE: struct_order_c was the fifth
carrier of the shared-scratch-directory class and it is fixed here, so
#2955's gate can be wired in a follow-up as its PR body said.

t27c 0 failing test binaries, tri 391 passed.

Refs #2948

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-30 15:25:47 UTC

Summary

Status Count
Total Open PRs 9
PRs with Failing Checks 7
PRs with All Checks Green 2
READY 1
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=4d82ec850541 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@gHashTag
gHashTag merged commit 594a746 into master Aug 30, 2026
28 checks passed
gHashTag added a commit that referenced this pull request Aug 30, 2026
…ratch gate (#2958)

* fix(ci): count the files this gate does not read, and wire the scratch gate

`check_pr_branch_filters.py` printed

    merge-critical workflows checked: 15
    workflow files present:           49
    explicitly not merge-critical:    4
    CLEAN: no merge-critical workflow filters pull_request by branch.

15 + 4 against 49, printed side by side and never subtracted. Thirty
files were read by nothing and the last line still said CLEAN.

Two of the thirty carried `pull_request: branches: [master]` -- the very
defect this check exists to detect. A branches filter on pull_request
means the gate does not run at all when a PR targets any other base, so
a stacked PR shows a green check list with that gate absent from it.
One of the two is corpus-ratchet, the expected-failure ledger.

Three parts:

  the two filters are removed -- `paths:` selects by what changed and
  stays, `branches:` selects by where the change is headed and is a hole;

  the third bucket is printed with its arithmetic closing against the
  file count, and the clean line says how many files remain unread
  instead of promising more than it checked;

  MAX_UNCLASSIFIED = 27, down only. Twenty-seven files cannot be
  classified in the commit that finds them, and a gate red on the day it
  lands teaches everyone to ignore red. The ceiling buys that the NEXT
  workflow cannot land unread. The same read runs over the unclassified
  files and is REPORTED, not failed: whether one should block a merge is
  a human call, whether anybody looked is not. That count is 0 and is
  printed as a zero rather than omitted.

Controls, each seen failing on purpose: a 28th unclassified workflow
gives UNCLASSIFIED ROSE 27 -> 28; a name in both lists gives IN BOTH
LISTS; restoring the filter on corpus-ratchet gives BRANCH-FILTERED
MERGE-CRITICAL WORKFLOWS. All restored, exit 0.

Also here: harness-scratch.yml, which #2955 held back because the gate
stayed red until #2949 removed the fifth carrier of that class. Both
have landed -- on master the command reports `none` and `--gate` exits
0. It carries no `paths:` filter and a push trigger on master, and its
header says why: emit-bitexact-gate.yml is pull_request with a paths
filter and no push, so it has never run on master, and when a change
made it fail there was no baseline to compare against. Historical
control: against the tree before #2955 the gate exits 1 and names all
five carriers.

The remaining 27 are a work list, not a verdict; l1-traceability.yml is
the one worth deciding first.

Closes #2957
Refs #2954
Refs #2919

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skill): 395 -- a guard's clean line is a claim about a population

The two list sizes and the file count were printed three lines apart and
never subtracted: 15 + 4 against 49 present, so 30 workflow files were
read by nothing and the summary still said CLEAN. Two of the thirty
carried the defect the check exists to detect.

Make the parts sum out loud; name the remainder as a third state; run
the same read over it and report rather than fail; ceiling not refusal
so the next addition cannot land unread; and let the final line state
the scope it actually earned.

Refs #2957

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@gHashTag

Copy link
Copy Markdown
Owner Author

Correction — a claim in this text is wrong and is withdrawn in #2959.

This issue says emit-bitexact-gate.yml "has never run on master". It has run on master twice, both by workflow_dispatch:

2026-08-28  workflow_dispatch  success   run 33150988445
2026-08-20  workflow_dispatch  failure   run 32319733329

The 2026-08-28 success is a usable baseline from two days before the change it was invoked about. I produced the wrong reading with gh run list --branch master -L 40 filtered by name — a window over all workflows, not a per-workflow lifetime query — and tri gates unmeasured already answers this correctly and does not flag that file.

What survives, and is the part this issue actually turns on: no push: trigger means no baseline is produced automatically. That is true, and #2959 measures it — 17 workflows are in that state, 5 of them merge-critical, one of those a required context.

The commit message on this branch carries the same wrong sentence. It cannot be edited without a force-push, so the correction lives in #2959, in the rewritten skill section 389, and here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

C backend: a self-referencing struct cannot be ordered, only forward-declared (24 specs)

1 participant