Skip to content

fix(data,export,parser): one space per control character, and pin the quoted-keyword header shapes (#3284) - #3294

Merged
louistrue merged 5 commits into
mainfrom
fix/source-header-quote-aware-3284
Aug 26, 2026
Merged

fix(data,export,parser): one space per control character, and pin the quoted-keyword header shapes (#3284)#3294
louistrue merged 5 commits into
mainfrom
fix/source-header-quote-aware-3284

Conversation

@BIMvoice

@BIMvoice BIMvoice commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Refs #3284.

Stacked on #3279 (fix/schema-detect-file-schema-3278), not on main — the two touch the same file. Same arrangement as #3261 on #3255. Please merge #3279 first; the diff against main will then be exactly what is below.

Item 1 — the quote-unaware header searches

Reproduced on upstream/main @ 8dd8a9db1 by running main's parseSourceHeader and #3279's side by side over the same bytes (the two shapes from the issue, unchanged):

MAIN 1a: undefined
MAIN 1b: {"description":["per the FILE_NAME convention"],"implementationLevel":"2;1",
          "author":[],"organization":[],"schemaIdentifiers":["IFC4"]}
HEAD 1a: {"description":["note: the ENDSEC; marker is described here"],...,"name":"a.ifc",
          "author":["Jane"],...,"schemaIdentifiers":["IFC4"]}
HEAD 1b: {... full header ...}

So the searches themselves are already fixed on this branch's base: #3279 landed indexOfRecord (used for both ENDSEC and each FILE_* keyword) while this was being written. That closes the connection the issue's follow-up comment describes — detectSchemaVersion no longer falls back to the raw byte scan for these two header shapes, so #3279 is complete rather than holed.

What this PR adds is the coverage #3279 does not have. Its tests assert the detected schema, and in shape 1b the schema was never wrong: FILE_SCHEMA parsed fine while every FILE_NAME field was silently empty. A header can carry the right schemaIdentifiers and have lost its author, timestamp and originating system — the provenance erasure #3282 is about, reached from the other door. packages/parser/test/source-header-quoted-keywords.test.ts asserts all ten fields, by name.

Mutation check (the assertion can fail): reverting the two call sites to main's indexOf reds exactly the two hostile cases and nothing else —

× a quoted ENDSEC does not truncate the header away (#3284 item 1a)
    AssertionError: expected undefined to be defined
× a quoted FILE_NAME does not shadow the real record (#3284 item 1b)
    AssertionError: expected undefined to be 'a.ifc'
✓ parses every declared field from a plain header
✓ still stops at the real ENDSEC and never reads the DATA section
✓ returns undefined for input with no header records at all

restored by the inverse edit, diff byte-identical.

The negative controls matter here: the plain-description case pins that the fix did not break ordinary headers, the DATA-section case pins the other direction (quote-awareness must not switch the terminator off — a FILE_NAME/FILE_SCHEMA planted after ENDSEC must not win), and the non-STEP case pins that undefined still means "no header", not "the scan tripped".

Item 2 — the escapers disagree on a run of control characters

packages/data/src/step-serializers.ts and packages/export/src/step-serialization.ts both used /[\x00-\x1F\x7F]+/g — note the + — collapsing a whole run to one space. ifc_lite_export::step_text::escape maps each control character to its own space. Each escaper's doc comment claims it matches the other; one of them had to move.

The expectations are derived from the Rust half, not written by hand: a throwaway #[test] printed escape()'s output over six inputs and was then removed (rust/export/src/step_text_tests.rs is byte-identical, diff-proven, and belongs to #3283 anyway):

RUST tab run   -> "a   b"
RUST crlf      -> "a  b"
RUST mixed     -> "a    b"      // NUL, VT, US, DEL
RUST single    -> "a b"
RUST quote+run -> "O''Brien  x"
RUST plain     -> "plain text 123"

Those six vectors are now the TS tests in both packages. ISO 10303-21 6.3.3.4 mandates neither behaviour (it only bars the control byte from a literal), so the tie is broken by the parity claim and by information loss: collapsing discards the run's length. The TypeScript halves move.

Mutation check: putting the + back reds 9 tests in @ifc-lite/data and 5 in @ifc-lite/export, with the run length named in the message —

AssertionError: expected '\'a b\'' to be '\'a   b\''
AssertionError: expected 'O\'\'Brien x' to be 'O\'\'Brien  x'
AssertionError: expected 'FILE_NAME(\'output.ifc\',\'TS\',(\'a …' to contain '(\'a   b\')'

restored by the inverse edit, diff byte-identical in both files. 'plain text 123' is the negative control (byte-identical passthrough), and the length loop asserts both directions at once: same length as the input and no surviving control byte — a run left intact would also keep its length, so neither half alone would have failed the old collapse.

Honest severity

Unchanged from the issue: both shapes are constructed. No real authoring tool is known to emit either, and nothing here is a reproduced field failure. Item 1's searches are robustness that turned out to be load-bearing for #3279; item 2 is a real, observed disagreement between two halves that each document themselves as matching the other.

Sweep

  • /[\x00-\x1F\x7F]+/g — the only two occurrences in the repo, both fixed here.
  • Non-quote-aware header locators in TS — one other site, packages/cli/src/commands/extract-entities.ts's text.indexOf('DATA;'). Same shape, different (and much narrower) blast radius: the scan that follows it is quote-aware and header lines carry no #. Left alone deliberately rather than widening this PR; noted here so it is on the record.

Verification

@ifc-lite/data:   205 tests passed (16 files)
@ifc-lite/export: 886 passed | 30 skipped (67 files)
@ifc-lite/parser: 773 passed | 2 skipped (77 files)
pnpm typecheck (data, export, parser): 11 tasks successful
pnpm lint: 3,662 files across 4 targets, 98 rules, no errors

🤖 Generated with Claude Code

https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

…ext (#3278)

`detectSchemaVersion` substring-scanned the raw first 2000 bytes for
`IFC4` / `IFC4X3` / `IFC2X3`. Those bytes are the STEP HEADER, which
includes the free-text author, organisation, preprocessor and
originating-system fields of `FILE_DESCRIPTION` and `FILE_NAME` — and
exporters routinely stamp a schema token into their product name. The
scan could not tell `FILE_SCHEMA(('IFC2X3'))` apart from an exporter
called "SomeApp IFC4 Exporter", and since `IFC4` was tested before
`IFC2X3`, the free text won.

A second, independent failure in the same function: ISO 10303-21 places
`FILE_SCHEMA` *after* `FILE_NAME`, so a long author or organisation list
pushes the declaration past 2000 bytes. A clean IFC2X3 file with no
`IFC4` anywhere in it then fell through every branch to the IFC4 default.

Both reach an output. `schedule-extractor` picks the IfcTask attribute
layout from `schemaVersion`, and the layouts diverge from index 5 on, so
a misdetected IFC2X3 file reports `.F.` as the work method and shifts
Status, WorkMethod and Priority by one.

Read the declaration from the source header instead. `parseSourceHeader`
already extracts `schemaIdentifiers` quote- and nesting-aware, caps the
decode at 64 KB and truncates at the header's `ENDSEC`, and `parseLite`
already called it on every parse — so this costs nothing on the hot path
and removes a redundant 2000-byte decode in the common case. Identifiers
are matched by prefix, longest first, so `IFC4X3_ADD2` and `IFC4X1` keep
resolving as before. When no `FILE_SCHEMA` identifier resolves, the old
raw scan remains the fallback rather than a refusal, so every file that
resolves today resolves the same way.

The function moves to `source-header.ts` beside the parser it now reads
from, which drops `columnar-parser.ts` 12 lines; its allowlist row is
ratcheted down to match and `ALLOWLIST_DIGEST` re-pinned in this commit.
Digest-pin conflict only. This branch's single allowlist difference from main is
columnar-parser.ts ratcheted DOWN (1237 -> 1225); ALLOWLIST_DIGEST recomputed
from a clean gate run on the merged tree.
Allowlist and digest-pin conflicts. main's columnar-parser.ts row (1211) is the
lower of the two and the merged file measures 1199, so the branch's own 1225 row
goes away and the allowlist ends byte-identical to main's -- which also means the
next main merge has nothing to conflict on here. No row moved up.
#3278)

Same rule as the rest of this PR -- header FREE TEXT is not a declaration -- one
level down. extractRecordArgs located its keyword with a plain indexOf and
parseSourceHeader truncated at the first ENDSEC the same way, so a
FILE_DESCRIPTION item containing a verbatim copy of the header (a file
round-tripped through a tool that quotes what it read) beat the real record.

Measured on the fixture the new cases build, old discovery vs new:

  quoted FILE_SCHEMA  OLD => "(''IFC2X3'')"        NEW => "('IFC4X3_ADD2')"
  quoted ENDSEC       OLD => null                    NEW => "('IFC4X3_ADD2')"
  control             OLD => "(''IFC4X3_ADD2'')"   NEW => "('IFC2X3')"

The quoted ENDSEC case is the worse half: it cut the header before FILE_SCHEMA,
so detection fell back to the raw 2000-byte scan this PR exists to stop relying
on. The control runs the same hostile description with the opposite real
declaration, so the two cases above cannot be passing because the detector
answers one value.

indexOfRecord tracks STEP string state ('' is the escape) and reports only
occurrences outside a quoted string.
… quoted-keyword header shapes (#3284)

Two STEP-header disagreements between the Rust and TypeScript halves.

1. `parseSourceHeader`'s `ENDSEC` / `FILE_*` searches were not quote-aware,
   so a `FILE_DESCRIPTION` carrying the literal text `ENDSEC;` truncated the
   header before any record was seen (`undefined`, whole header lost) and a
   literal `FILE_NAME` in prose shadowed the real record (all seven FILE_NAME
   fields lost). The scan itself is fixed on this branch's base, #3279, which
   landed `indexOfRecord` while this was being written; what is added here is
   the coverage that fix does not have. #3279's tests assert the detected
   SCHEMA, which is unchanged in shape 1b — the header can carry the right
   `schemaIdentifiers` and still have lost its author, timestamp and
   originating system. The new test names all ten fields.

2. Both TypeScript escapers collapsed a RUN of control characters to a single
   space (`/[\x00-\x1F\x7F]+/g`) while `ifc_lite_export::step_text::escape`
   maps each control character to its own space, so `"a\t\t\tb"` was written
   `'a b'` by TypeScript and `'a   b'` by Rust — with each escaper's doc
   comment claiming it matched the other. ISO 10303-21 6.3.3.4 mandates
   neither (it only bars the control byte from a literal); preserving the
   count loses no information, so the TypeScript halves move. The expected
   strings in the new tests are the Rust half's observed output over the same
   six inputs, not hand-written guesses.

Refs #3284
@BIMvoice
BIMvoice requested a review from louistrue as a code owner August 26, 2026 04:45
@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ad71890-5157-481e-821e-bc9358a54c1d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@louistrue

Copy link
Copy Markdown
Collaborator

Heads up that #3297 and this PR overlapped, and I have removed the overlap from mine rather than have two changes doing the same thing.

Both of us fixed the control-character run in packages/data/src/step-serializers.ts and packages/export/src/step-serialization.ts, same direction, same reasoning. You were first by about twenty minutes and you are the issue's author, so this is the one that should land. I have reverted both files on my branch to main, so there is nothing left to conflict.

Your version is also the better test. Mine pinned the literal "a b"; yours asserts the length relation at every run length, which is a property rather than an example and cannot pass by coincidence.

Two things I kept, because they do not overlap:

A Rust test pinning the run length on the third half. rust/export/src/step_text_tests.rs. The parity claim had a hole neither of our PRs closed: escape_maps_every_ascii_control_char_to_a_space feeds one control character at a time, and per-character and collapse-a-run give the same answer for a run of one, so nothing on the Rust side ever asserted the rule the TypeScript side is being made to match. Mutating step_text::escape to skip a push when the output already ends in a space leaves the existing test green and reddens only the new one.

The header scan, which is a different defect. #3297 is now just source-header.ts and its Rust mirror. It turns out indexOfRecord landing via #3279 fixed the quoted-keyword half but left comments: an apostrophe in /* John's export */ inverts quote state for the rest of the file, a comment between a keyword and its ( drops that record, and a comma inside a comment shifts every later field along.

One note relevant to your PR's framing. You wrote that #3279 is "complete rather than holed" for these two header shapes, and that is right for the shapes in the issue. It is not right in general: the same parse_source_header returning None on a commented header makes export_step fall back to its own defaults, so the source file's author, organization and authorization get replaced with ifc-lite. Same provenance erasure as #3282, reached through a third door. That is fixed in #3297 now, both halves, so it is not a gap in yours.

@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Adversarial review of 0126e82c1. I verified RED on both halves myself and mutation-tested the parser pin.

What I ran

Premise first — the whole PR rests on Rust being one-space-per-char. rust/export/src/step_text.rs:44-66:

for c in s.chars() {
    match c {
        ...
        '\0'..='\u{1F}' | '\u{7F}' => out.push(' '),

Per character, no run collapsing. The premise is correct and the fix direction (TS follows Rust) is right.

GREENpnpm exec turbo run test --filter=@ifc-lite/data --filter=@ifc-lite/export:

@ifc-lite/data:test     Test Files  16 passed (16)     Tests  205 passed (205)
@ifc-lite/export:test   Test Files  65 passed | 2 skipped (67)   Tests 886 passed | 30 skipped (916)

No displaced expectation anywhere — nothing in either package depended on the collapsed form.

RED, independently — restored + in both escapers (packages/data/src/step-serializers.ts:205, packages/export/src/step-serialization.ts:151), keeping the tests:

@ifc-lite/data     Tests  9 failed | 196 passed (205)
   AssertionError: expected '\'a b\'' to be '\'a   b\''
   AssertionError: expected '\'O\'\'Brien x\'' to be '\'O\'\'Brien  x\''
@ifc-lite/export   Tests  5 failed | 881 passed (916)
   AssertionError: expected 'a b' to be 'a   b'

Both negative controls (single control char, no control chars) stayed green in both suites, which is what makes the 9/5 meaningful. Restored by copying back the pre-mutation files; git status clean.

The changeset claim — "Both TS escapers":

$ grep -rn 'x00-\\x1F' packages apps | grep -v node_modules | grep -v /dist/
packages/export/src/step-serialization.ts:151
packages/data/src/step-serializers.ts:205

Exactly two. Accurate. Changeset starts with ---, no licence header, both affected published packages listed at patch. Correct.


1. This collides with #3297 — the same one-character source change, in the same two files

#3297 (Refs #3284, open, based on main) contains:

-    .replace(/[\x00-\x1F\x7F]+/g, ' '); // Collapse control chars
+    .replace(/[\x00-\x1F\x7F]/g, ' ');  // One space PER control char
-    .replace(/[\x00-\x1F\x7F]+/g, ' ');
+    .replace(/[\x00-\x1F\x7F]/g, ' ');

Byte-identical to this PR's source change, in packages/data/src/step-serializers.ts and packages/export/src/step-serialization.ts. It also carries its own escaper-parity tests (packages/export/src/step-escaper-parity.test.ts) and its own packages/parser/test/source-header.test.ts (+101) over #3284 item 1 — the same ground as this PR's source-header-quoted-keywords.test.ts (+173). Two open PRs implementing one issue in the same files; whichever lands second will conflict. Flagging for a merge-order decision rather than as a defect in either.

2. The base branch is merged, so this needs retargeting — and item 1 is already fixed on main

base=fix/schema-detect-file-schema-3278#3279 is MERGED. And the quote-aware scan this PR's parser test pins is already on main:

$ git show upstream/main:packages/parser/src/source-header.ts | grep -n indexOfRecord
141:function indexOfRecord(upper: string, keyword: string, fromIndex = 0): number {

So source-header-quoted-keywords.test.ts is a characterisation pin over shipped behaviour, not a RED-then-GREEN fix. The title says "pin the quoted-keyword header shapes", which is honest — no overclaim — but it means the file has to be judged on whether it is capable of failing. I checked, by replacing the body of indexOfRecord with return upper.indexOf(keyword, fromIndex):

✓ parses every declared field from a plain header
× a quoted ENDSEC does not truncate the header away (#3284 item 1a)      expected undefined to be defined
× a quoted FILE_NAME does not shadow the real record (#3284 item 1b)     expected undefined to be 'a.ifc'
✓ a quoted FILE_DESCRIPTION does not shadow the real record either
✓ still stops at the real ENDSEC and never reads the DATA section
✓ returns undefined for input with no header records at all
Tests  2 failed | 4 passed (6)

Restored, byte-identical, tree clean. Two of the six cases are load-bearing. That is a real pin.

3. Minor: the FILE_DESCRIPTION case cannot detect the shadowing it says it detects

From the run above, 'a quoted FILE_DESCRIPTION does not shadow the real record either' stays green under a plain indexOf. Its own comment claims it covers "the third keyword the same scan looks up, and the one whose shadowing would be self-concealing" — but in headerBytes() the quoted FILE_DESCRIPTION( text sits inside the real FILE_DESCRIPTION record, i.e. always after it, so a naive left-to-right indexOf finds the genuine record first and the case passes either way. It is a fine regression pin for the escaping of the description text; it is not evidence about record shadowing. Either soften the comment, or move the decoy so it can actually win — e.g. put the quoted FILE_DESCRIPTION(('x'),'2;1') text inside the FILE_NAME record, which precedes nothing that would rescue it.

Not blocking; the two cases that matter do fail.


Cleared: the Rust reference vectors (all six checked against step_text::escape's per-char branch), the ordering constraint (control-char replacement before the \X2\ directive encoding, matching Rust's single match), the keeps the run length … at every length two-direction assertion (length and absence of control bytes — neither alone would catch the collapse), the REQUIRED_FIELDS named-field anti-vacuity in the header test, and the never reads the DATA section inverse case with the planted FILE_NAME('planted.ifc', …, ('Mallory'), …), which is exactly the right shape for "quote-awareness must not turn the terminator off".

@BIMvoice
BIMvoice changed the base branch from fix/schema-detect-file-schema-3278 to main August 26, 2026 07:29
@louistrue

Copy link
Copy Markdown
Collaborator

Not merging this: it reads green because almost nothing ran, not because it passed.

I came here to merge it — it is MERGEABLE, has no failing checks, and I had just confirmed it carries the one half of #3284 still outstanding on main. Then I counted the check runs:

#3294   4 check runs
#3298  26 check runs
#3306  34 check runs

No Typecheck, no Lint, no Node tests, none of the four viewer shards, and no Build + WASM + Rust + Node — the aggregate that branch protection actually gates on. The two real entries are parity and a Vercel preview comment.

The mechanism, from the timeline: this PR was retargeted (base_ref_changed) at 07:29:03, and its last commit is 04:44:23. It was stacked on fix/schema-detect-file-schema-3278 and moved to main after that base landed. GitHub does not re-run the checks on a retarget, so what is showing is whatever ran against the old base — and against a base that no longer exists, most of it never ran at all.

So "no failing checks" here is the absent-CI-reads-as-green shape: the rollup cannot distinguish "passed" from "never ran", and mergeable is about textual conflicts, not about tests.

To clear it, the head needs to move so the workflows fire against main — an empty commit (git commit --allow-empty) or a rebase onto current main will do it. I have not pushed to your branch.

The content is right, for what it is worth, and I checked before writing this off: main still carries /[\x00-\x1F\x7F]+/g in both TS escapers (zero hits for the per-character form), so "a\t\t\tb" still serialises as 'a b' here and 'a b' in Rust. This PR fixes exactly that, in both escapers. I have added Closes #3284 so it closes the issue when it lands — it was carrying no closing keyword.

Also worth knowing: the other half of #3284 is already done. indexOfRecord landed via #3279, and I ran the issue's own quoted-ENDSEC fixture against main — the header parses and the description survives, where it previously returned undefined. So this PR is the remainder, not a duplicate of that.

On the overlap with #3297: that one is CONFLICTING with Node tests and the aggregate red, head unchanged since 05:08, and its central addition indexOfOutsideQuotes does the job main's indexOfRecord now does. I have said on #3284 that this PR looks like the survivor, but that is a recommendation for its author to weigh, not a decision I am making for them.

@louistrue

Copy link
Copy Markdown
Collaborator

Sharper cause than I gave before, and it generalises past this PR.

test.yml never ran on this PR at all — not once, at any head. Only one workflow has ever been created for 0126e82c:

IfcOpenShell parity   completed/success

test.yml declares on: pull_request: branches: [main]. This PR was opened at 04:45 against fix/schema-detect-file-schema-3278, so the trigger did not match and the workflow was never created. Retargeting to main at 07:29 does not fire it retroactively — GitHub evaluates the trigger when the event happens, not when the base changes.

So it is not "CI did not re-run". It is that the required lane has never existed for this branch, and the rollup has nothing to report a failure from.

The general shape, which is worth someone knowing: any PR stacked on a feature branch gets no test.yml, and retargeting it to main leaves it that way. It then presents as MERGEABLE with no failing checks — indistinguishable from green at a glance. #3246 was in the same state earlier today and only got real CI once its base landed and it was retargeted before a push.

The remedy is unchanged: the head has to move. git commit --allow-empty -m "retrigger CI on main" and push, or a rebase onto current main. I have not pushed to your branch.

The content still looks right — main carries /[\x00-\x1F\x7F]+/g in both TS escapers (zero hits for the per-character form), so "a\t\t\tb" is 'a b' here and 'a b' in Rust, and this fixes exactly that. Closes #3284 is on the body.

@louistrue

Copy link
Copy Markdown
Collaborator

Do not merge this yet. It reads MERGEABLE with zero failures, and it has never been compiled, typechecked, linted or tested.

This PR carries 8 checks. A healthy PR on this repo carries 38. The missing lanes:

Typecheck                     Lint
Node tests                    Rust tests
Build packages + WASM         Build + WASM + Rust + Node   (the required aggregate)
Viewer tests (shards 0-3)     Viewer E2E smoke
Rust crate semver             Detect changes
... 30 lanes in total

What it does have is parity, three Vercel deployments, Vercel Agent Review, CodeRabbit and Vercel Preview Comments. None of those builds the code.

Cause: test.yml never fired. The PR was opened against a feature branch, and retargeting it to main does not fire workflows retroactively. So the checks that exist are the ones that ran against the original base, and the whole test matrix simply never started.

Why no failure count catches this. The required aggregate is not red, it is absent. fail=0 is literally true, and so is mergeable. Anything asking "is anything failing?" gets the answer "no" and is correct — it is just answering a different question from "has this been tested?".

The fix is one push of any kind — an empty commit, a rebase onto current main, anything creating a new head event now that the base is main. That fires the full matrix. I have not pushed it myself; it is your branch.

Worth doing before that push, since the base changed: this has never been merged against current main, which has moved a long way today (#3298's semver gate, #3306's sharded module-size digest, and eleven other merges). A rebase rather than an empty commit will tell you whether it still applies, not just whether it compiles in isolation.

#3185 has the identical shape — 6 checks, same missing set — but it is CONFLICTING so it cannot merge by accident. Same treatment when it is resolved.

For anyone sweeping the board: count the checks before reading their status. gh pr view <n> --json statusCheckRollup --jq '.statusCheckRollup|length'. Under ~15 on a non-fork PR means a workflow never ran, and no amount of reading the green ones reveals it.

@louistrue
louistrue merged commit 36350e8 into main Aug 26, 2026
8 checks passed
@BIMvoice

Copy link
Copy Markdown
Collaborator Author

Checked the three findings the bots raised on #3297 (the conflicting duplicate of this PR) against this branch. None of the three applies here. A fourth problem does, and it is now fixed: 87370e6c0e3fe2145c3e746feff85df90e215723.

The three #3297 findings, against this branch

1. Missing @ifc-lite/export in the changeset (Codex, CodeRabbit) — does not apply. #3297 puts the escaper change in a single changeset listing only parser and data. This PR splits it in two, and the one covering packages/export/src/step-serialization.ts already declares export:

--- .changeset/hungry-pugs-sneeze.md
'@ifc-lite/export': patch
'@ifc-lite/data': patch

.changeset/tidy-schemas-declare.md covers @ifc-lite/parser for source-header.ts / columnar-parser.ts. Every changed packages/* path is attributed.

2. Source-text assertion at source-header.test.ts:516 (CodeRabbit) — does not apply. git diff upstream/main...HEAD --name-only does not list packages/parser/test/source-header.test.ts; the file is byte-identical to main here and is 332 lines long, so there is no line 516. The new test files (source-header-quoted-keywords.test.ts, schema-detection.test.ts) assert on parsed results (header?.name, parseSourceHeader(...)), never on fixture text. node scripts/check-source-text-assertions.mjsOK (8 allowlisted, 0 marked, 0 new).

3. Wrong license header in rust/export/src/schema_detect.rs (CodeRabbit) — does not apply. This PR touches no Rust at all; git diff upstream/main...HEAD --name-only | grep rust/ is empty. There is no schema_detect.rs. The four new/changed TypeScript files all carry the block MPL notice, which is the TS convention here (matching packages/parser/src/source-header.ts and packages/export/src/step-serialization.ts); the SPDX-only form the finding asks for is the rust/export/src convention, which nothing here is under.

A fourth finding that does apply — check-module-size fails

Given that test.yml has never run on this PR at any head, I ran the cheap gates and the affected suites locally. One gate fails on this branch and passes on main:

Allowlisted file(s) grew PAST their recorded budget. Shrink or split instead of
raising the budget:

  packages/data/src/step-serializers.ts: 465 lines, budget 459

node scripts/check-module-size.mjs exits 1 here, exits 0 on upstream/main. It runs in test.yml line 636, so it would have fired the first time that workflow ran.

The escaper change itself was one line; the +6 was all doc comment. Fixed by tightening that comment back to its original five lines, keeping the fact it records — the count is per control character, and the + this carried until #3284 wrote "a\t\t\tb" as 'a b' where the export escaper and ifc_lite_export::step_text::escape write 'a b'. The budget is not raised.

Verification after the fix

check result
check-module-size OK (1953 files, 310 allowlisted, 0 new over 400)
check-test-wiring OK (47 packages, 41 gate scripts)
check-test-glob-coverage OK (47 packages, 0 unrun test files)
check-source-text-assertions OK (8 allowlisted, 0 marked, 0 new)
@ifc-lite/data 205 passed (16 files)
@ifc-lite/parsersource-header-quoted-keywords, schema-detection, source-header 50 passed (3 files)
@ifc-lite/exportstep-serialization 33 passed

These are local runs, not CI. They are not a substitute for test.yml actually running on this head.

louistrue added a commit that referenced this pull request Aug 26, 2026
main is red: `packages/data/src/step-serializers.ts` is 465 lines against a
budget of 459, so `check-module-size` exits 1 on every branch cut from it.

MY FAULT, and the mechanism is worth recording. #3294 was one of two PRs whose
`test.yml` never fired -- opened against a feature branch, retargeted to main,
and retargeting does not fire workflows retroactively. It carried 8 checks where
a healthy PR carries 38, so the module-size gate was ABSENT rather than red and
`fail=0` was literally true. I found that, posted it on the PR, and then merged
the PR anyway while "exercising" a patch to my merge gate that had silently
failed to apply.

Nothing functional is wrong with #3294. Its content is right and its tests pass
(data 212, parser 825, export 944, typecheck 0). The only breakage is the six
lines it added to this file, all of them comment.

Fixed by SHRINKING to the budget rather than raising it, which is the rule the
allowlist header states and the one I held #3249 to earlier today. Both edits
are compressions of existing prose with every claim preserved: the per-character
vs per-run rationale, the `ifc_lite_export::step_text::escape` parity note, the
ISO 10303-21 6.3.3.4 citation, the mojibake failure mode and its evidence
(IfcOpenShell#699/#1016, Solibri). No functional line is touched.

459 lines, check-module-size exit 0.
BIMvoice added a commit that referenced this pull request Aug 26, 2026
…s for, and test the loop (#3312)

Adversarial review findings on the #3312 gate. Two of them are the defect the
gate exists to prevent, one level up: a check that fires when nothing is wrong.

BLOCKING -- THE 420 s BUDGET COULD NOT COVER WHAT IT WAITED FOR.
`Build + WASM + Rust + Node` is downstream of twelve jobs and publishes no check
run until all of them finish. Measured over the twelve most recent non-cancelled
`test.yml` PR runs, `Detect changes` start to aggregate start, in seconds:

  670  732  756  761  776  777  778  780  782  933  1163  1312

Not one run fit in 420 s. On timeout the gate exits 1 printing "the workflow
never fired for this head ... push an empty commit" over a green PR -- a
false-alarm generator. Its own green run cleared by 15% only because test.yml
picked up runners ahead of it.

Fixed with TWO changes, because either alone still false-fails:

  1. `excludeJobKeys: ["test"]`. Requiring the aggregate ties this budget to the
     SUITE's total runtime, which grows with the suite; excluding it ties the
     budget to runner PICKUP, which does not. Over the same twelve runs the last
     NON-aggregate lane started at 159..678 s. Nothing is lost -- `gh api
     repos/.../rulesets/11806334` returns exactly two required contexts and the
     aggregate is one of them, so branch protection blocks on it already.
  2. 900 s, not 420 s. Excluding the aggregate is not sufficient: 535 s and
     678 s were both observed, so 420 s would still have false-failed 2 of the
     12. `timeout-minutes` goes to 20 so an exhausted budget still prints.

The #3294 detection is unaffected and asserted under the SHIPPED config: that
rollup shape still exits 1 naming all fifteen remaining lanes, and a test proves
the exclusion removes the aggregate and nothing else.

ROOT CAUSE -- THE POLL LOOP HAD NO TEST. `--state-file` mode hardcodes
`timedOut: false` and jumps to `evaluate`, so the harness drove the verdict and
never the wait; the untested branch was the broken one. The loop moves to
`pollForLanes` in the lib over an injected clock, sleep and re-read, with seven
tests including both timeout paths (budget exhausted, and a deadline already
past), the settle short-circuit, and the twelve measured pickups against 900 s
and against 420 s. Four mutations checked: reporting a timeout as not-timed-out
(4 fail), checking the deadline an interval late (2), removing the settle rule
(1), removing the re-read (2).

STALE BASE WAS MISDIAGNOSED AS A RETARGET. Live on #3301: `MISSING_LANES: Rust
crate semver`, remedy "push an empty commit". There was no retarget -- #3298
added `rust-semver` to test.yml AFTER that head, so the lane cannot exist there
and re-firing the same workflow file gives the same absence. The required set
comes from the test.yml in THIS checkout while the rollup may be older. The
verdict was right; the remedy was advice that cannot work. The message now
branches on total vs partial absence: total absence keeps the #3294 retarget
remedy, partial absence says test.yml DID fire, names how many lanes are
present, and points at a rebase. Verified live against #3301.

`reviewVerdictSeverity` SHIPS AS `warn`. A rate-limited status NEVER self-heals:
the complete history on such a SHA is `queued -> in progress -> success/Review
rate limited`, then nothing, forever (verbatim from `statuses/{sha}` on #3296's
head). The quota recovers; the status on that commit does not. So `fail` means
red until a human pushes or triggers an on-demand review -- true on 8 of 19 open
PRs today. That is the class this repo already ruled on when it marked
check-coderabbit-review.mjs and check-pr-green.mjs `@unwired-by-design`, and the
docblock quoted that ruling while shipping the opposite. The finding is still
printed and still quotes the reviewer verbatim; the escalation path is still
tested, now against an explicit `fail` config rather than against the default.
louistrue added a commit that referenced this pull request Aug 26, 2026
… merged

Neither blocked that merge; both are mine.

A DOC THAT SURVIVED ITS OWN MECHANISM. `find_unquoted`'s comment sends the
reader to `last_comment_close` for the linearity argument and describes the
closer search as HOISTED. I replaced that design mid-branch with a deferred
search and a `no_closer` memo on `Lex`, and deleted the function, but the
comment two files away still described the old shape. `grep -rn "fn
last_comment_close" rust/export/src/` returns nothing.

That is exactly the failure #3284 is about, committed by the fix for it: a
comment invalidated at a distance by a refactor, still confidently describing
a mechanism that no longer exists. Now it names the memo and says why the memo
is what makes the bound hold.

AN ASSERTION THAT COULD NOT TELL TWO ANSWERS APART.

    assert!(h.is_none() || h.unwrap().schema_identifiers.is_empty());

passes whether the reader REJECTS the malformed `FILE_SCHEMA\u{00A0}(...)` or
ACCEPTS it and returns an empty list. The comment directly above claims the
first. So the test agreed with itself either way, and if the reader ever began
accepting that record it would still be green.

It returns None today, so that is what is pinned now. Mutation-verified rather
than assumed: teaching `skip_trivia` to treat 0xC2, the UTF-8 lead byte of
U+00A0, as whitespace makes the reader accept the record, and the tightened
assertion reddens where the old one did not.

This also gives the PR a new head event, which it needs for a second reason:
`gh run list --branch fix/3284-followup-ascii-fold` returned NOTHING, so
test.yml never fired when the branch was pushed and the PR opened. Seven lanes
registered, none of them a test lane, and the required aggregate absent. Same
shape as #3294, on my own PR, which is what #3313 exists to catch.

Verified by exit code: cargo test -p ifc-lite-export 0.
louistrue pushed a commit that referenced this pull request Aug 26, 2026
…ed without reviewing (#3312) (#3313)

* fix(ci): fail a PR whose test lanes never ran, and a review that passed without reviewing (#3312)

Two checks, both about ABSENCE rather than failure, both measured rather than
argued.

Lane presence, by NAME. The expected check names are derived from test.yml's
`jobs:` block (matrix expanded), not pinned as a count: a floor of 15 is
satisfied by 15 Vercel deploys and survives losing the one lane that compiles
the code. Presence, not status — a path-filtered job still publishes a skipped
check run, so this asks only "did the workflow fire".

No-verdict reviews. Reads each configured reviewer's free-text description,
which `gh pr view --json statusCheckRollup` does not expose at all, and fails
when a `success` state sits on top of a phrase saying nothing was reviewed.
`neutral`/`failure` are left alone: they already communicate "no verdict".

Fires on `edited`, which is the whole point — retargeting a PR's base does not
re-fire the workflows a `branches: [main]` filter had excluded, which is the
deterministic mechanism behind #3294 merging with 8 checks.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(ci): make the review-signal gate's poll budget cover what it waits for, and test the loop (#3312)

Adversarial review findings on the #3312 gate. Two of them are the defect the
gate exists to prevent, one level up: a check that fires when nothing is wrong.

BLOCKING -- THE 420 s BUDGET COULD NOT COVER WHAT IT WAITED FOR.
`Build + WASM + Rust + Node` is downstream of twelve jobs and publishes no check
run until all of them finish. Measured over the twelve most recent non-cancelled
`test.yml` PR runs, `Detect changes` start to aggregate start, in seconds:

  670  732  756  761  776  777  778  780  782  933  1163  1312

Not one run fit in 420 s. On timeout the gate exits 1 printing "the workflow
never fired for this head ... push an empty commit" over a green PR -- a
false-alarm generator. Its own green run cleared by 15% only because test.yml
picked up runners ahead of it.

Fixed with TWO changes, because either alone still false-fails:

  1. `excludeJobKeys: ["test"]`. Requiring the aggregate ties this budget to the
     SUITE's total runtime, which grows with the suite; excluding it ties the
     budget to runner PICKUP, which does not. Over the same twelve runs the last
     NON-aggregate lane started at 159..678 s. Nothing is lost -- `gh api
     repos/.../rulesets/11806334` returns exactly two required contexts and the
     aggregate is one of them, so branch protection blocks on it already.
  2. 900 s, not 420 s. Excluding the aggregate is not sufficient: 535 s and
     678 s were both observed, so 420 s would still have false-failed 2 of the
     12. `timeout-minutes` goes to 20 so an exhausted budget still prints.

The #3294 detection is unaffected and asserted under the SHIPPED config: that
rollup shape still exits 1 naming all fifteen remaining lanes, and a test proves
the exclusion removes the aggregate and nothing else.

ROOT CAUSE -- THE POLL LOOP HAD NO TEST. `--state-file` mode hardcodes
`timedOut: false` and jumps to `evaluate`, so the harness drove the verdict and
never the wait; the untested branch was the broken one. The loop moves to
`pollForLanes` in the lib over an injected clock, sleep and re-read, with seven
tests including both timeout paths (budget exhausted, and a deadline already
past), the settle short-circuit, and the twelve measured pickups against 900 s
and against 420 s. Four mutations checked: reporting a timeout as not-timed-out
(4 fail), checking the deadline an interval late (2), removing the settle rule
(1), removing the re-read (2).

STALE BASE WAS MISDIAGNOSED AS A RETARGET. Live on #3301: `MISSING_LANES: Rust
crate semver`, remedy "push an empty commit". There was no retarget -- #3298
added `rust-semver` to test.yml AFTER that head, so the lane cannot exist there
and re-firing the same workflow file gives the same absence. The required set
comes from the test.yml in THIS checkout while the rollup may be older. The
verdict was right; the remedy was advice that cannot work. The message now
branches on total vs partial absence: total absence keeps the #3294 retarget
remedy, partial absence says test.yml DID fire, names how many lanes are
present, and points at a rebase. Verified live against #3301.

`reviewVerdictSeverity` SHIPS AS `warn`. A rate-limited status NEVER self-heals:
the complete history on such a SHA is `queued -> in progress -> success/Review
rate limited`, then nothing, forever (verbatim from `statuses/{sha}` on #3296's
head). The quota recovers; the status on that commit does not. So `fail` means
red until a human pushes or triggers an on-demand review -- true on 8 of 19 open
PRs today. That is the class this repo already ruled on when it marked
check-coderabbit-review.mjs and check-pr-green.mjs `@unwired-by-design`, and the
docblock quoted that ruling while shipping the opposite. The finding is still
printed and still quotes the reviewer verbatim; the escalation path is still
tested, now against an explicit `fail` config rather than against the default.

* fix(ci): hold the settle verdict across a measured interval, and correct the budget's own numbers (#3312)

Third review round on #3313.

1. THE SETTLE-RULE RACE. `rollupSettled` answered "has everything published
   so far finished", which is not "will anything else publish". A downstream
   job's check run is created only when its `needs` complete, so every fan-out
   boundary has an instant where every published lane is terminal and more are
   still coming. Replaying all 71 completed test.yml PR runs of 2026-08-25/26
   at 1 s resolution: 31 contain such an instant, 36 windows, EVERY ONE exactly
   1 s wide. Run 32930088375 (conclusion: success) has three, at t=266/415/1386
   s; a read at t=266 s sees `Detect changes` alone, terminal, and the shipped
   rule calls that 13 of 14 required lanes permanently absent — on a green run,
   under the "rebase onto main" remedy.

   The fix requires the settled verdict to HOLD, unchanged, across
   SETTLE_HOLD_SECONDS (60 s) rather than merely to occur. An elapsed interval
   rather than "two consecutive reads" because two reads is really one
   `--poll-seconds`, so its guarantee shrinks or vanishes with that flag; an
   interval is denominated in the same unit as the thing being raced. The
   assumption is stated in the code rather than left implicit: GitHub never
   takes more than 60 s to create the next fan-out wave after the last
   published lane goes terminal (measured max 1 s, a 60x margin), and a
   violation produces a false FAIL, never a false PASS.

   Pinned by replaying that run's real job timestamps from every second of its
   first 500 — held rule right everywhere, un-held rule wrong on exactly the
   46 start seconds whose 15 s schedule lands in a window.

2. THE MARGIN PROSE WAS MEASURED THE WRONG WAY. The previous figures were
   `started_at`; the gate polls for PRESENCE, which is `created_at`, and the
   two diverge hard (run 32930088375: `Lint` created at 416 s, started at
   1037 s). Re-measured from run creation over the 68 completed test.yml PR
   runs of 2026-08-25/26 that published the aggregate:
     - last non-aggregate lane appears: 161 / 190 / 522 / 845 s (min / median /
       p95 / max) — 0 of 68 breach 900 s, so the budget holds, but the tail
       margin is 900/845 = 1.07x, not the 1.33x claimed;
     - the aggregate appears: 509 / 894 / 2067 s — 33 of 68 breach 900 s, so
       `excludeJobKeys: ["test"]` is far more load-bearing than the PR claimed.
   Corrected in the workflow comment, the config comment, both script headers,
   the aggregate-exclusion test comment, and the pinned constant itself.

3. `--timeout-seconds` / `--poll-seconds` now fail closed as BAD_DURATION on
   anything non-finite or non-positive. A NaN deadline is never in the past, so
   the poll would have spun to the job timeout and printed nothing at all —
   the no-output shape this gate exists to reject.

Mutations, all killed: hold=0 (4 tests), signature reset removed (1), the
non-settled reset removed (1), the non-finite fallback removed (1), the
duration guard removed (2).

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(ci): the PR read and the status reads must name the same repository (#3312)

`main()` resolved `repo` from `--repo` ?? GITHUB_REPOSITORY and handed that to
`fetchStatusDescriptions` and `fetchCheckRunDescriptions`, but handed the raw
`args.repo` to `fetchPrState` -- null whenever only the environment variable is
set, which is every CI run. `gh pr view` then resolved the repository from the
checked-out git remote. The rollup and the review descriptions could describe
two different repositories, and the PR read failed outright outside a git
checkout. Harmless today only because the two happen to coincide.

`repo` is now a required parameter of `fetchPrState` and `--repo` is passed
unconditionally, so there is no longer a way to ask for the divergent read.

The test drives the live path against a stand-in `gh` first on PATH that
records every argv, and asserts every one of the three reads names the same
resolved repository. Mutation-checked: restoring `repo: args.repo` and the
conditional `--repo` push turns it red.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* docs(ci),test(ci): scope the self-guard claim to the branches it holds on (#3312)

CodeRabbit's finding, verified against the workflow. The comment claimed
"neither the script nor its config can be edited without the job that runs them
firing". `branches: [main]` twelve lines below means that is false for a PR
based on a feature branch: both files can change there with no gate run.

The filter is NOT the defect and is not being widened. The required lane set is
derived from test.yml, which carries the same `branches: [main]`, so on a
feature-targeted PR every required lane is legitimately absent and this gate
would fail every stacked PR for a reason that is not a defect. The prose was
the thing that was wrong, so the prose is what changed: the claim now says what
it holds for, and names the retarget rule that bounds the residue -- pointing
such a PR at main fires `edited`, which runs this job, and it must pass before
the change reaches main.

That reasoning turns entirely on the two workflows carrying the SAME
base-branch filter, and they are edited independently, so it is now asserted
rather than argued: a test reads `branches:` out of both files and requires
them equal. Blind in one direction (test.yml runs, the gate does not) and
noisy in the other (the gate runs, no lane can). Mutation-checked: widening
either list turns it red.
louistrue added a commit that referenced this pull request Aug 26, 2026
…lected IFC5 (#3284) (#3315)

* Fold ASCII in the last-resort schema scan, and make its tests able to fail

Follow-up to #3297, which merged at the head I had pushed rather than the one
I had finished. Three commits did not make it, and one of them is a real fix
rather than polish, so this lands them.

THE FIX. `detectSchemaVersion`'s fallback uppercases the first 2000 bytes and
looks for `IFC5` / `IFC4X3` / `IFC4` / `IFC2X3` as substrings.
`'ı'.toUpperCase()` is `'I'`, so a FILE_DESCRIPTION mentioning `ıFC5` selects
IFC5 for a file that never said so. Still live on main at source-header.ts:294.

Same fold #3297 removed from the record scan, one function further down the
same file. The scan is deliberately loose -- it only runs when no FILE_SCHEMA
identifier resolves, and it already matches `IFC4` inside ordinary prose --
but loose is not a reason to accept a fold ISO 10303-21 does not use. A copy
is fine here where it was not in the record scan, because nothing takes
offsets from it.

THE TESTS THAT COULD NOT FAIL. My first two tests for this did not exercise
the fold at all: one asserted the trailing IFC4 default, which passes for any
implementation that fails to match, and the other fed input already
upper-case. An identity mutant on the helper was killed by ZERO tests across
the whole parser suite. There is now a case that drives the direction the fold
exists for, lower-case `ifc4x3` in prose, plus one for the subtler mutant that
DROPS non-ASCII rather than passing it through: deleting a character joins the
fragments either side, so `IFCı5` becomes `IFC5`, a match built from a
character that was never in the word.

Off-by-one bounds on the fold survive and are left alone deliberately. The
output is consumed only by `.includes()` on tokens whose letters are i, f, c
and x, so neither `a` nor `z` can appear in a match and nothing through the
public surface can distinguish them.

A FALSE CLAIM, replacing a stale one. #3297 rewrote a comment in
`schema-version-detection.test.ts` that wrongly said `detectSchemaVersion` is
module-private, and replaced it with a different wrong claim: that
`buildStep()` can never reach the last-resort scan. It always emits a
FILE_SCHEMA record but not always a RESOLVABLE one, and the `IFC2X2` case
falls through to the scan. Proven by putting a throw at the top of the scan
and watching only that test go red.

Also: `schema_detect.rs` uses the crate's SPDX one-line header like every
sibling, both changeset fences declare a language, and the changeset says the
`ıFC5` input falls through to the IFC4 default rather than "no longer selects
a schema", since `detectSchemaVersion` always returns one.

Verified by exit code: parser 849, rust export 0, typecheck 0, lint 0,
module-size 0. Mutation-verified: restoring `toUpperCase()` reddens the new
test and only it.

* Drop the license-header change, and say what the fold gives up

Preflight came back clean on the fix itself and raised two small things.

The SPDX header swap on `schema_detect.rs` has nothing to do with the ASCII
fold, so it is out. It was a CodeRabbit suggestion I took on the original
branch, and it is defensible -- 52 of 54 files in `rust/export/src` already
use the one-line form -- but `LICENSE_HEADER.md` still documents the block
comment as required for `.rs`, and `scripts/add-license-headers.mjs` matches
only that form. So the repo has an in-flight migration with a stale doc and a
stale script, and quietly adding one more file to the wrong side of it in a
parser fix is not the way to settle that. Filing it separately.

The changeset now says what the fold costs rather than only what it fixes: a
Turkish-locale `ıfc4x3` in free header prose used to resolve and no longer
does. It is the same character as the false positive being removed, pointed
the other way, and a reader of release notes should see both. ISO 10303-21
tokens are ASCII and this scan only runs for a file that declares no
resolvable schema, so the trade is worth making, but it is a trade.

Also `source-header.test.ts`'s own docstring claimed the file is direct
coverage for `parseSourceHeader`. It now tests `detectSchemaVersion` too, and
the sibling comment in `schema-version-detection.test.ts` -- rewritten in this
same work -- points at it for exactly that. The two now agree.

Verified by exit code: parser 849, typecheck 0, lint 0, module-size 0.

* Two corrections to what #3297 shipped, both found by the CLI after it merged

Neither blocked that merge; both are mine.

A DOC THAT SURVIVED ITS OWN MECHANISM. `find_unquoted`'s comment sends the
reader to `last_comment_close` for the linearity argument and describes the
closer search as HOISTED. I replaced that design mid-branch with a deferred
search and a `no_closer` memo on `Lex`, and deleted the function, but the
comment two files away still described the old shape. `grep -rn "fn
last_comment_close" rust/export/src/` returns nothing.

That is exactly the failure #3284 is about, committed by the fix for it: a
comment invalidated at a distance by a refactor, still confidently describing
a mechanism that no longer exists. Now it names the memo and says why the memo
is what makes the bound hold.

AN ASSERTION THAT COULD NOT TELL TWO ANSWERS APART.

    assert!(h.is_none() || h.unwrap().schema_identifiers.is_empty());

passes whether the reader REJECTS the malformed `FILE_SCHEMA\u{00A0}(...)` or
ACCEPTS it and returns an empty list. The comment directly above claims the
first. So the test agreed with itself either way, and if the reader ever began
accepting that record it would still be green.

It returns None today, so that is what is pinned now. Mutation-verified rather
than assumed: teaching `skip_trivia` to treat 0xC2, the UTF-8 lead byte of
U+00A0, as whitespace makes the reader accept the record, and the tightened
assertion reddens where the old one did not.

This also gives the PR a new head event, which it needs for a second reason:
`gh run list --branch fix/3284-followup-ascii-fold` returned NOTHING, so
test.yml never fired when the branch was pushed and the PR opened. Seven lanes
registered, none of them a test lane, and the required aggregate absent. Same
shape as #3294, on my own PR, which is what #3313 exists to catch.

Verified by exit code: cargo test -p ifc-lite-export 0.
louistrue added a commit that referenced this pull request Aug 27, 2026
…t mean the merge gate ran (#3334)

Two ways `disqualify()` returned "nothing wrong with this PR" over a pull request
nobody should merge. Both measured against the real exported function on main:

    disqualify(mergedShapedRow)  ->  null
    severityOf(mergedShapedRow)  ->  10   (SEVERITY.GREEN)

1. A PR THAT IS NOT OPEN SCORED GREEN. `mergeable` reads `UNKNOWN` on a MERGED
   pull request exactly as it does on one GitHub has not finished computing, so
   nothing below could separate them, and a merged row arrived with 39 passing
   runs and disqualified to `null`. Confirmed live on #3315:
   `{"state":"MERGED","mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN"}`. Only
   `--state open` at the call site was keeping it out of the report, which is the
   filter any `--pr N` entry point would bypass. `state` is now the FIRST
   disqualifier: once a PR is merged or closed, every count below it describes
   history rather than a merge.

2. A POPULATED ROLLUP DID NOT MEAN THE MERGE GATE RAN. Measured on #3315 at
   66c8886: five workflow runs at the head and `test.yml` among them zero times
   (ifcopenshell-parity x2, python-wheels, server-binaries, xmatch-fixture). That
   is the #3294 shape wearing a non-zero count. The sweep now counts lanes whose
   `workflowName` is the merge gate's, read out of the `statusCheckRollup` that
   `gh pr list` already returns.

REWORKED AFTER REVIEW, and the review was right on all three counts:

  - The first draft bought fact 2 with a second `gh api` call per PR, +36 calls
    on a 36-PR sweep, for something already in hand. Reading `workflowName` off
    the rollup removes the call AND the hazard its comment documented, since
    there is no longer a SHA parameter to get wrong.
  - `NOT_OPEN` shared rank 0 with `NOT_OURS`, so `assert.equal(severity,
    NOT_OPEN)` would have passed on a NOT_OURS row and the added clause in
    `actionable()` could not change any result. It now has its own rank and the
    cases assert the reason text too.
  - THE ONE THAT MATTERED: `state` was added to the `--json` list and never
    copied onto the row, so branch 1 was reachable only from hand-built
    fixtures and was dead in production, while the fixture comment claimed the
    opposite. Fetched-and-discarded is worse than not fetched: it reads as done.

Every branch is proved to fire and not to fire, and the inertness above is now
itself pinned:

    baseline                     29 pass,  0 fail
    NOT_OPEN branch removed      27 pass,  2 fail
    Test-lane branch removed     28 pass,  1 fail
    row drops `state` again      28 pass,  1 fail

That last line is the point. Before the integration case, dropping the `state`
copy left 28 of 28 green: the unit cases build rows by hand, so not one of them
could tell whether the producer populates the field they read. A guard that
cannot catch its own regression is the defect it is guarding against.

The `green()` fixture and `onePr` gain the fields for the same reason. `onePr`
previously carried a bare `{status, conclusion}` rollup entry, which the sweep
read as healthy: a populated rollup with nothing from the merge gate in it, the
exact state branch 2 exists to refuse.

Coupled to the workflow's display name (`test.yml:5` is `name: Test`), pinned in
one exported constant. A rename makes the count 0 and reports "never fired" on a
head where it did, which is a false alarm rather than a false green.

HARDENED AFTER REVIEW, because the first fix was the instance and not the class.
`state` was made to disqualify on absence and pinned with an integration case,
and its sibling `testLaneCount` was left with the identical hole one line below:
`undefined === 0` is false, so an absent count sails past, and deleting the field
from the row constructor left 29 of 29 green while the production branch went
dead. A truthiness guard on `state` had the mirror defect, scoring a row with
`state: null` as GREEN. Both now say what the module already says about an
unreadable run count: unknown is not green.

    baseline                         30 pass,  0 fail
    producer drops `state`           27 pass,  3 fail
    producer drops `testLaneCount`   28 pass,  2 fail
    state-absence guard removed      29 pass,  1 fail
    count-absence guard removed      29 pass,  1 fail

Confirmed against live data rather than only fixtures: #3327 is an open PR of
ours, CLEAN and MERGEABLE, whose only run at the head is the parity workflow and
which carries zero Test lanes. The old code scored it green.

No new script: this is the lib the sweep already uses, covered by
`scripts/lib/*.test.mjs` in test.yml. A `scripts/merge-decide.sh` would have been
invisible to the repo's own gate-name checks.
louistrue added a commit that referenced this pull request Aug 27, 2026
* Adding anonymizer-export for debug

* fix(parser): the last-resort schema scan folded Unicode, so `ıFC5` selected IFC5 (#3284) (#3315)

* Fold ASCII in the last-resort schema scan, and make its tests able to fail

Follow-up to #3297, which merged at the head I had pushed rather than the one
I had finished. Three commits did not make it, and one of them is a real fix
rather than polish, so this lands them.

THE FIX. `detectSchemaVersion`'s fallback uppercases the first 2000 bytes and
looks for `IFC5` / `IFC4X3` / `IFC4` / `IFC2X3` as substrings.
`'ı'.toUpperCase()` is `'I'`, so a FILE_DESCRIPTION mentioning `ıFC5` selects
IFC5 for a file that never said so. Still live on main at source-header.ts:294.

Same fold #3297 removed from the record scan, one function further down the
same file. The scan is deliberately loose -- it only runs when no FILE_SCHEMA
identifier resolves, and it already matches `IFC4` inside ordinary prose --
but loose is not a reason to accept a fold ISO 10303-21 does not use. A copy
is fine here where it was not in the record scan, because nothing takes
offsets from it.

THE TESTS THAT COULD NOT FAIL. My first two tests for this did not exercise
the fold at all: one asserted the trailing IFC4 default, which passes for any
implementation that fails to match, and the other fed input already
upper-case. An identity mutant on the helper was killed by ZERO tests across
the whole parser suite. There is now a case that drives the direction the fold
exists for, lower-case `ifc4x3` in prose, plus one for the subtler mutant that
DROPS non-ASCII rather than passing it through: deleting a character joins the
fragments either side, so `IFCı5` becomes `IFC5`, a match built from a
character that was never in the word.

Off-by-one bounds on the fold survive and are left alone deliberately. The
output is consumed only by `.includes()` on tokens whose letters are i, f, c
and x, so neither `a` nor `z` can appear in a match and nothing through the
public surface can distinguish them.

A FALSE CLAIM, replacing a stale one. #3297 rewrote a comment in
`schema-version-detection.test.ts` that wrongly said `detectSchemaVersion` is
module-private, and replaced it with a different wrong claim: that
`buildStep()` can never reach the last-resort scan. It always emits a
FILE_SCHEMA record but not always a RESOLVABLE one, and the `IFC2X2` case
falls through to the scan. Proven by putting a throw at the top of the scan
and watching only that test go red.

Also: `schema_detect.rs` uses the crate's SPDX one-line header like every
sibling, both changeset fences declare a language, and the changeset says the
`ıFC5` input falls through to the IFC4 default rather than "no longer selects
a schema", since `detectSchemaVersion` always returns one.

Verified by exit code: parser 849, rust export 0, typecheck 0, lint 0,
module-size 0. Mutation-verified: restoring `toUpperCase()` reddens the new
test and only it.

* Drop the license-header change, and say what the fold gives up

Preflight came back clean on the fix itself and raised two small things.

The SPDX header swap on `schema_detect.rs` has nothing to do with the ASCII
fold, so it is out. It was a CodeRabbit suggestion I took on the original
branch, and it is defensible -- 52 of 54 files in `rust/export/src` already
use the one-line form -- but `LICENSE_HEADER.md` still documents the block
comment as required for `.rs`, and `scripts/add-license-headers.mjs` matches
only that form. So the repo has an in-flight migration with a stale doc and a
stale script, and quietly adding one more file to the wrong side of it in a
parser fix is not the way to settle that. Filing it separately.

The changeset now says what the fold costs rather than only what it fixes: a
Turkish-locale `ıfc4x3` in free header prose used to resolve and no longer
does. It is the same character as the false positive being removed, pointed
the other way, and a reader of release notes should see both. ISO 10303-21
tokens are ASCII and this scan only runs for a file that declares no
resolvable schema, so the trade is worth making, but it is a trade.

Also `source-header.test.ts`'s own docstring claimed the file is direct
coverage for `parseSourceHeader`. It now tests `detectSchemaVersion` too, and
the sibling comment in `schema-version-detection.test.ts` -- rewritten in this
same work -- points at it for exactly that. The two now agree.

Verified by exit code: parser 849, typecheck 0, lint 0, module-size 0.

* Two corrections to what #3297 shipped, both found by the CLI after it merged

Neither blocked that merge; both are mine.

A DOC THAT SURVIVED ITS OWN MECHANISM. `find_unquoted`'s comment sends the
reader to `last_comment_close` for the linearity argument and describes the
closer search as HOISTED. I replaced that design mid-branch with a deferred
search and a `no_closer` memo on `Lex`, and deleted the function, but the
comment two files away still described the old shape. `grep -rn "fn
last_comment_close" rust/export/src/` returns nothing.

That is exactly the failure #3284 is about, committed by the fix for it: a
comment invalidated at a distance by a refactor, still confidently describing
a mechanism that no longer exists. Now it names the memo and says why the memo
is what makes the bound hold.

AN ASSERTION THAT COULD NOT TELL TWO ANSWERS APART.

    assert!(h.is_none() || h.unwrap().schema_identifiers.is_empty());

passes whether the reader REJECTS the malformed `FILE_SCHEMA\u{00A0}(...)` or
ACCEPTS it and returns an empty list. The comment directly above claims the
first. So the test agreed with itself either way, and if the reader ever began
accepting that record it would still be green.

It returns None today, so that is what is pinned now. Mutation-verified rather
than assumed: teaching `skip_trivia` to treat 0xC2, the UTF-8 lead byte of
U+00A0, as whitespace makes the reader accept the record, and the tightened
assertion reddens where the old one did not.

This also gives the PR a new head event, which it needs for a second reason:
`gh run list --branch fix/3284-followup-ascii-fold` returned NOTHING, so
test.yml never fired when the branch was pushed and the PR opened. Seven lanes
registered, none of them a test lane, and the required aggregate absent. Same
shape as #3294, on my own PR, which is what #3313 exists to catch.

Verified by exit code: cargo test -p ifc-lite-export 0.

* fix(ci): close the path-filter holes, and gate the class that made them (#3312) (#3314)

* fix(ci): close the path-filter holes, and gate the class that made them (#3312)

A CI gate is only as good as the job that runs it, and that job only runs when
the path filter says so. When a gate's INPUT sits outside its own TRIGGER the
gate is not weak, it is unreachable: the PR that introduces the very mistake it
guards against is the PR the job skips, and a skipped job counts as success in
the aggregate `test` gate, so the required check goes green.

Four instances, each reproduced against a real merged PR or the wiring itself:

  - `scripts/check-swallowed-push.mjs` declares its SCOPE to be
    `.github/workflows/**` and ran in Node tests, which only `test.yml` and
    `server-binaries.yml` could trigger -- unreachable on 11 of the 13 files it
    guards. PR #3118 edited `release.yml` and `docker.yml`; Node tests SKIPPED.
  - `pnpm test:integration` runs `tests/integration.test.ts`, which was in no
    filter: the test could not trigger its own execution.
  - `scripts/docs/generate-docs-sections.mjs --check` regenerates from
    `tests/benchmark/baseline.json` and `apps/landing/app.jsx`, neither in any
    filter. PR #1817 changed only `apps/landing/bench-data.json`; Node tests AND
    Docs checks both skipped and the required check reported success.
  - `apps/landing/**` was in no filter at all.

`tests/extensions/**` did NOT reproduce and is not fixed here: `sdk-canary.yml`
carries `tests/extensions/canaries/**` in its own `paths:`.

THE FIXES, each in the cheapest filter that reaches the gate:

  frontend  += `.github/workflows/**` (subsumes the two individual entries it
              had), `tests/integration.test.ts`, `tests/tsconfig.json`
  docs      += `apps/landing/**`, `tests/benchmark/baseline.json`

`docs` rather than `frontend` for the last two on purpose: it reaches the same
`--check` through the free Docs-checks job -- one ubuntu-latest runner, three
node scripts, no build artifact and no Depot -- instead of dragging a
landing-copy edit through build + typecheck + lint + the viewer shards. Coverage
holds either way, because a PR that also touches frontend/rust makes Docs checks
skip itself and Node tests runs the same check. The workflow addition is the one
that costs: a workflow-only PR now runs the JS lane. Every job it adds is free
except `build`, which reaches Depot only when the WASM source has drifted from
the published release tag -- the same condition every frontend PR already pays.

THE DURABLE PART is `scripts/check-ci-path-coverage.mjs`, which derives gate
inputs from the gate scripts and fails when one is outside its trigger. For
every workflow it reads which `node scripts/...` gates each job runs, the globs
that can trigger that job, and the repo paths each gate reads out of its own
source; then it reports every path a gate reads and no glob can reach.

It fails closed. No workflows, no PR-triggered jobs, no gates, a filter block
that parses to nothing, a referenced gate script that is missing, a job gating
on an undefined filter, a missing allowlist, an exemption with no written
reason, an exemption that stopped matching, zero derived inputs -- each is a
NAMED failure, never a pass. Its own config is inside its own trigger, proved by
`assertSelfCoverage` rather than asserted in prose. `REQUIRED_COVERAGE` pins the
six specific facts above by name, because a count floor survives dropping the
one entry that matters.

`check-ci-path-coverage.test.mjs` is the executable proof: 27 tests covering
every fail-closed path, the glob and parsing semantics, and -- against a
symlink mirror of the real repo -- the removal of each of the four filter
entries, asserting the report names the specific file each time.

The 37 residue entries in the allowlist are each written out with a reason. The
one that is a trade rather than a technicality: the four gates that walk `apps/`
still cannot be triggered from `apps/landing`, because closing that needs
`apps/landing/**` in `frontend`, which the filter block already declines to do.
The walk matches zero files there today -- apps/landing ships unbuilt .jsx/.html
/.css with no TypeScript, no test file and no WASM handle. If TypeScript lands
there, the exemption is wrong and the lines say so.

Refs #3312

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(ci): correct the workflow counts and re-file a non-hole in the path-coverage gate

Review findings on #3314, all accuracy rather than behaviour. A gate about
reachability has to be accurate about what it measured.

- `ls .github/workflows | wc -l` is 15, not 13. Two prose counts corrected:
  "2 of the 13" -> "2 of the 15" in the gate's docblock, and "unreachable on
  11 of the 13 files it guards" -> "13 of the 15" in test.yml (15 workflows,
  2 named in the filter at the time, so 13 unreachable, not 11).

- The step scan matches only a literal `node scripts/*.mjs` in a `run:`, so a
  gate invoked through a package script is outside the census: `pnpm lint`
  runs four of them, and `check:vitest-timeout-audit` and `fixtures:check` run
  one each. All six were walked by hand and none is outside its own trigger
  today, so this is a stated LIMIT, not a fix. Recorded in the docblock rather
  than left for the next reader to rediscover.

- Section 3 of the allowlist is headed "Real holes", and one entry was not one.
  `check-report-numerals.mjs` carries
  `relRaw.startsWith('scripts/') || relRaw.startsWith('docs/')`; the derivation
  keeps the bare `scripts` and `docs` those normalise to. Its real roots are
  `VISION_DIR = 'docs/vision'` and the bet directories under scripts/moonshot,
  and moonshot.yml's `on.pull_request.paths` carries `docs/vision/**` and
  `scripts/moonshot/**` -- so moonshot.yml has no hole here. Moved to section 1
  (PREFIX FRAGMENTS), which is what it is.

Both entries still match (a stale exemption is a named failure), and deleting
them still reopens the 124-input report, so the re-filing is a relabel and not
a weakening.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(ci): the path-coverage verdict must be a function of the commit (#3312)

The new check passed on a clean checkout and failed in CI on the IDENTICAL
commit -- 15 findings, nine of them the single word `node_modules`. Its input
derivation and its tree walk both read the WORKING tree, so whatever happened
to be on disk changed the answer: `node_modules` after an install, a package's
`dist` after a build, the fetched `.ifc` corpus under `tests/models` after the
fixture cache warmed. None of those are committed, so none of them can ever be
what a `paths:` filter matches -- but all three were being reported as gate
inputs outside their own trigger.

The old skip set was the near miss: it filtered a walk's CHILDREN and never the
root node the walk was asked about, so `node_modules` as a derived input
enumerated the whole install.

The walk and the derivation now both exclude what `.gitignore` excludes, read
from the committed file rather than by shelling out to `git check-ignore`, so
the synthetic trees in the harness -- which are not repositories -- run the same
exclusion the real repository runs rather than a second behaviour nothing
tests. `gitignoreToGlobs` refuses negations and escapes instead of dropping
them, because a silently dropped pattern is a tree the walk wanders back into.

`.gitignore` is consequently an INPUT to this gate, and the gate said so on the
first run: an edit to it can turn a covered path into an uncovered one. Added
to the `frontend` filter, which is the cheapest one reaching Node tests.

Tests: five over `gitignoreToGlobs` (depth, anchoring, the zero-directory
`a/**/b` case that kept the corpus visible, trailing slash, negation refused),
one pinning the real ignore file translates and still admits `manifest.json`,
and two end-to-end -- an installed `node_modules` and a warmed fixture cache
must each leave the report BYTE-IDENTICAL. Mutation-checked: reverting the
derivation to the bare `exists` predicate turns the `node_modules` one red.

* fix(ci): the trigger parser must refuse a shape it cannot read (#3312)

CodeRabbit's finding on scripts/lib/ci-path-coverage.mjs, verified against the
branch rather than taken on the description. Both halves reproduce.

An INLINE list -- `paths: ['rust/**']` -- returned `{ paths: null }`. The block
matcher requires an empty tail after the colon, so an inline list fell through
to the "some other key" branch and left `paths` at its initial `null`. `null`
is not a degraded answer here, it is the OPPOSITE answer: the caller reads it
as "this workflow triggers on every path", the widest coverage claim there is,
asserted about a workflow that is in fact narrowly filtered. Every gate input
under such a workflow would look reachable. That is the precise defect class
this check exists to find, in the check itself.

An UNQUOTED entry inside a recognised block was silently omitted. That one errs
the safe way -- a short trigger list under-claims coverage and over-reports --
but a finding derived from a truncated list is indistinguishable from a real
hole, so it throws too.

Both now throw, and the checker catches at the workflow boundary and NAMES the
file, because these parsers throwing IS the check firing and an uncaught stack
trace leaves the reader to work out which of 26 workflows produced it.

Tests: the three refusals, plus one asserting the block, `paths-ignore` and
no-`paths` forms still parse -- the refusals must not have been bought by
refusing everything -- plus an end-to-end run over a mirror carrying an
inline-`paths` workflow, asserting the report names it and is not a stack.
Mutation-checked: disabling either refusal turns the matching test red.

* feat(export): native merged/federated IFC export at parity with the JS MergedExporter (#2951) (#2952)

* refactor(export): split merged.rs into a merged/ module

Move the monolithic merged.rs into merged/mod.rs and its tests into
merged/tests.rs (via the sibling mod tests; include the house pattern
uses), with no logic change. This creates the module directory the native
merged-export parity work (#2951) lands its submodules into.

* feat(export): native merged-export parity — GlobalId reconciliation, spatial merge, visibility (#2951)

Bring the native Rust merged exporter (rust/export/src/merged) up from the
id-offset-only "P1" to feature parity with the JS MergedExporter, so a native
consumer can federate models entirely in Rust without materializing the merge
in a webview JS heap (the OOM class this addresses).

- guid.rs: deterministic 22-char GlobalId minter (byte-identical to the JS
  deterministicGlobalId, pinned against golden values) + rooted-entity
  detection denylist + read/replace helpers. Duplicate GlobalIds are now
  unified (same unit space) or re-stamped (relationships / federated), so a
  merged file no longer carries duplicate GlobalIds.
- spatial.rs: match IfcSite / IfcBuilding / IfcBuildingStorey onto the first
  model by name / elevation (single / by-name / by-elevation /
  by-name-then-elevation, +-0.5-unit tolerance).
- plan.rs: per-model index, visibility forward-reference closure, reference
  rewriting, and redundant-IfcRelAggregates pruning.
- units.rs: length-scale resolution + compatibility.
- mod.rs: orchestrator wiring project/infra unification, spatial merge,
  GlobalId reconciliation, per-model visibility, and unit handling into
  export_merged_models, plus extended MergedOptions / MergedStats.

Cross-unit rescaling (unitReconciliation 'normalize') is deferred: an
incompatible-unit model is federated (never silently mis-scaled) and
MergedStats.unit_rescale_required is set so the caller can gate that case to
the JS path — permitted as a first-iteration limitation by the spec.

cargo test -p ifc-lite-export and the workspace clippy gate are clean.

* test(export): add merge_ifc example harness for large federations

A runnable harness that reads several IFC files from disk, merges them
natively via export_merged_models, writes one .ifc, and self-checks the
result (duplicate GlobalIds, dangling references, unified IfcProject).
This is the native path a webview-embedding consumer would drive instead
of the JS MergedExporter, and the tool used to confirm a ~1.6 GB / 11-model
federation merges without the WebView2 out-of-memory crash (#2951).

* fix(export): address PR review on the native merged exporter (#2951)

Five reviewer findings on the merged export, verified against the code and fixed:

- Filtered canonical targets dangle (Greptile P1 / CR): canonical_project,
  first_infra and spatial_lookup were derived from the COMPLETE first model, so
  when models[0].included excludes its project / unit / a spatial container,
  later models still redirected refs onto those never-emitted ids. Now the
  first-model merge targets are filtered through resolve_included; an excluded
  canonical simply isn't a target and later models keep their own.
- Schema conversion duplicates GlobalIds (Greptile P1): a downgrade with no
  target type falls back to IFCPROXY with placeholder_guid(id). Two models
  sharing a source-local id seeded the same GlobalId. Pass the OFFSET id so the
  proxy guid is globally unique (and consistent with the line's offset #id).
- Within-model mint collision (CR): GuidMinter::mint only checked prior models'
  emitted guids + its pending set, so a re-stamped guid could collide with an
  unchanged guid in the SAME model. mint now takes the current model's
  local_guids as an extra exclusion set.
- Type-aware GlobalId classification (CR): the harness/tests counted the first
  22-char quoted value as a GlobalId, misclassifying non-rooted entities that
  lead with a charset Name (IfcColourRgb, ...). Added those types to the
  rooted-entity denylist and a public leading_rooted_global_id helper (single
  source of truth with the merge's own extract_global_id_fast); the harness and
  tests use it. Regression coverage added.
- Harness federated project count (CR): the self-check failed valid federated
  output (projects <= 1); it now expects 1 + federated_model_count.

cargo test -p ifc-lite-export (36 merged tests) and the workspace clippy gate
pass.

* refactor(export): keep merged/mod.rs under the module-size ratchet (#2951)

The merged/ split left mod.rs at 425 lines, over the 400-line ratchet with no allowlist row (the failing rust-tests gate). Move the plan-building helpers (ModelPlan, PlanCtx, build_plan, reconcile_global_ids, model_salt) into plan.rs — their natural home beside ModelIndex/unify_spatial — dropping mod.rs to 303, and extract plan.rs's inline tests into a sibling plan_tests.rs (exempt via the _tests.rs suffix) so plan.rs stays at 374. Prefer splitting over allowlisting per AGENTS.md.

* fix(export): address second-round PR review on the native merged exporter (#2951)

Resolve the remaining CodeRabbit/Greptile findings on #2952:

- Schema-based rootedness: replace the hand-maintained non-rooted denylist with
  `is_rooted_entity_type` (`legacy_aware_ifc_type(..).is_subtype_of(IfcRoot)`),
  mirroring the JS exporter's IfcRoot inheritance check. A non-rooted resource
  leading with a 22-char Name (IfcColourRgb, IfcMaterialLayer,
  IfcRegularTimeSeries, IfcSimpleProperty) is no longer misread as a GlobalId.
- Within-model duplicate GlobalIds: reconcile every model (including the first)
  and track seen local GlobalIds, so two rooted entities in one model sharing a
  GlobalId re-stamp the later occurrence instead of emitting duplicates.
- Placeholder GUID collision: a schema-conversion IFCPROXY placeholder is minted
  after reconciliation, so re-stamp it at emit time if it collides with an
  already-emitted GlobalId.
- AssumeShared effective scale: store `primary_scale` (not the model's own), so
  a later model's shared GlobalId unifies rather than failing the units gate.
- EXPRESS id overflow: guard the cumulative offset with checked_add (stop and
  report `unmerged_model_count` instead of wrapping ids), and saturate ref-id
  parsing in rewrite_refs so a malformed wide ref can't wrap onto a valid id.
- Tests: full project->site->building->storey aggregation in the fixture with a
  remapped-endpoint assertion; per-unit-policy entity-count assertions; and
  regressions for within-model dup GlobalIds, AssumeShared cross-unit unify,
  the overflow guard, the schema rooted check, and the mint `also` collision.

* fix(export): tighten overflow bound and preserve source GlobalId on conversion (#2951)

Two further review findings on #2952:

- Capacity bound from VISIBLE entities: resolve `included` before the id-space
  overflow check and bound `checked_add` on the largest visible id, not
  `index.max_id`. An excluded near-max id no longer consumes id space or omits
  a later model that would actually fit.
- Keep the source GlobalId reachable after schema conversion: when a rooted
  entity is downgraded to an IFCPROXY placeholder, also map its SOURCE GlobalId
  onto the final id (via `entry().or_insert`, never overwriting a re-stamped
  duplicate), so a later compatible model carrying it unifies instead of emitting
  a second proxy.
- Regressions for both: an excluded max id followed by a fitting model, and two
  IFC4X3 models sharing an IfcAlignmentSegment GlobalId converted to IFC4.

* fix(export): cover legacy IFC2X3 rooted types the schema check misses (#2951)

is_rooted_entity_type used only `legacy_aware_ifc_type(..).is_subtype_of(IfcRoot)`,
which reaches `legacy_entities.rs` (21 element/geometry legacy names) then the
generated schema. But 38 rooted IFC2X3 resource types (IfcElectricalCircuit,
IfcCondition, IfcRelAssignsTasks, IfcServiceLife, IfcTimeSeriesSchedule, ...)
were dropped from IFC4X3 and are absent from both, so they resolve to
IfcType::Unknown and were classified as non-rooted. Their GlobalId then never
entered reconciliation and two models sharing one emitted it twice -- silent
GlobalId duplication, exactly the case the legacy rooted table exists to prevent
(reported on #2952; louistrue's "port both halves or neither").

Add a legacy IFC2X3 rooted-type fallback (`is_legacy_rooted_type`, the 54-entry
set kept in agreement with the JS exporter's IFC2X3 coverage), consulted only
when the schema does not recognise the type. The schema check stays primary, so
a type re-entering the generated schema simply stops reaching the fallback.

Split the guid.rs tests into a sibling guid_tests.rs (house pattern) so the
production module stays under the module-size ratchet. Regressions: unit-level
classification of six dropped rooted types, and an end-to-end merge of two
IFC2X3 models sharing an IfcElectricalCircuit GlobalId (emitted exactly once).

* test(export): re-adapt the two #3083 merge fixtures to the module's semantics

A second merge of origin/main re-took main's monolithic merged_tests.rs versions
of these two fixtures verbatim, undoing the adaptation from the first merge, so
they failed against the merged/ module (342 pass, 2 fail, reported on #2952).

Neither is a bug in the exporter -- both fixtures encoded main's old merged.rs
behaviour, which differs from this module:

- later_models_project_ref_redirects_to_the_first_models_project: the old
  fixture related the later project to ITSELF (#7,(#7)); after the project is
  unified BOTH endpoints point at it, so redundant-aggregation pruning correctly
  drops the row -- which read as the redirect vanishing. Relating the project to
  a DISTINCT wall keeps the row (only fully-unified aggregations are pruned) so
  the redirect onto model A's project id stays observable. Verified directly:
  the kept aggregation emits #1 (model A's project), not #7 nor its offset image.
- merge_mints_distinct_ids_for_collisions_within_the_same_model: this module
  unifies the first cross-model duplicate (same unit space) and re-stamps only
  the remaining within-model duplicates, so four rooted entities survive, not
  five. The real invariant is unchanged -- every emitted GlobalId is distinct.

Full crate green, clippy clean.

---------

Co-authored-by: Louis Trümpler <78563314+louistrue@users.noreply.github.com>

* docs(changeset): state the with_style_metadata break by signature, and the version it actually ships from (#3310)

* docs(changeset): state the with_style_metadata break by signature, and the version it actually ships from

The `rep-item-identity-across-boundary` changeset generates the Rust-API
paragraph of the next `@ifc-lite/cache`, `geometry`, `wasm` and
`server-client` CHANGELOG entries, so it is held to the code's accuracy bar.
Two things in it were not.

`6.0.0 → 6.1.0` was wrong when written and is wrong now: #3186 had already
bumped the Cargo workspace to `6.0.1`, and that commit is an ancestor of #3210,
which added this changeset. `Cargo.toml` on `main` reads `version = "6.0.1"`,
so the minor this changeset causes ships `6.0.1 → 6.1.0`.

`rust/export/src/usd/tests.rs` demonstrates only ONE of the two breaks. Its
whole diff in #3210 is `+ material_id: None,` inside a `MeshData` struct
literal. The arity break is demonstrated by `rust/processing/src/element.rs`,
whose call went from `with_style_metadata(material_name, geometry_item_id)` to
`with_style_metadata(material_name, source_id, id_is_material)`. Both are now
named, each against the file that shows it.

The "two arguments to three" claim itself is CORRECT and is kept, restated as
the two signatures so it cannot be misread as counting `self`: CodeRabbit
asked for it to be removed as false (#3227), and the diff of
`rust/processing/src/types/mesh.rs` in 50895fb5b says otherwise.

No behaviour change; changeset prose only.

* docs(changeset): the "nothing gates this" claim is no longer true

The last sentence of the BREAKING paragraph read "Nothing gates this: there is
no `cargo-semver-checks` anywhere in the repo." That was accurate when the
paragraph was written and stopped being accurate at 08:15 today, when #3298
merged `scripts/check-rust-semver.mjs` and its `Rust crate semver` lane; #3305
then added `rust-major-offset.json` at 14:56. `cargo-semver-checks` now appears
27 times across `.github/workflows/` and `scripts/` on main, so the sentence
asserts the absence of something the reader can grep and find.

Shipping it would put a false claim in the published changelog, in the one
paragraph whose entire job is to be accurate about a break the changeset format
cannot express -- and this PR exists only to make that paragraph accurate.

Replaced with what the gate actually does, checked against the source rather
than the PR description: it compares the required bump with the bump the
derived version carries over the crate's latest crates.io release and fails on
the smaller, its documented lint set covers BOTH breaks this paragraph names
(a field added to a `pub` struct callers construct literally, and a changed
argument count), it runs on PRs and again before publish, and the remedy for a
Rust-only major is the committed offset.

The two claims this PR does add both verify and are untouched:
`with_style_metadata(self, material_name, source_id, id_is_material)` is the
live signature at `rust/processing/src/types/mesh.rs:268`, three
caller-supplied arguments; and 6.0.1 is the highest npm workspace version, so
`6.0.1 -> 6.1.0` is the bump a `minor` here derives.

Refs #3227

* fix(viewer-embed): apply ?hideAxis= and ?hideScale= instead of only parsing them (#3316)

* fix(viewer-embed): apply ?hideAxis= and ?hideScale= instead of only parsing them

`parseUrlParams` accepted `?hideAxis=true` and `?hideScale=true`, stored them
on `EmbedUrlParams`, and nothing ever read them: a grep for `urlParams.hideAxis`
/ `urlParams.hideScale` across `apps/viewer-embed` matched the parser and its
own test, nothing else. `ViewportOverlays` took a single `hideViewCube` prop and
drew the scale readout and the axis helper unconditionally, so an embed that
asked for a bare viewport still got both.

`hideViewCube` — the fourth sibling, and the one that was wired — is the pattern
followed here: a prop on `ViewportOverlays` guarding the JSX, passed from the
embed's single call site. Both flags default to `false`, so the standalone
viewer renders exactly as before.

The guards drop their own item only. `BasepointToggleButton` shares the same
bottom-left column and stays reachable with both flags set; the scale
subscription (`setOnScaleChange`) is still registered when `hideScale` is on,
matching `hideViewCube`, which likewise leaves `setOnCameraRotationChange` in
place. With `hideAxis` on, `axisHelperRef.current` stays null and the rotation
callback's `axisHelperRef.current?.updateRotation(...)` is a no-op.

The new test renders the REAL `ViewportOverlays` inside the real `EmbedViewer`
(the sibling URL-param test mocks the overlays out) and asserts on the DOM the
embed produces. Every case asserts BOTH directions — the other overlay is still
present — and the no-param case asserts both are, so an implementation that
hides them always fails rather than passes.

`?controls=` is left parse-only deliberately: its four values are not pinned to
observable behaviour anywhere in the repo or the SDK docs, and guessing one
would be inventing protocol. Refs #2934.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* chore: retrigger CI

The required `parity (in-tree fixtures, committed reference)` check never
reported on c367123: its workflow run ended in `startup_failure` with zero
jobs during the GitHub Actions dispatch outage, and a startup failure cannot
be re-run. The branch is already current with main, and the quick parity job
is gated to `github.event_name == 'pull_request'`, so workflow_dispatch
cannot report the required context either.

Tree is byte-identical to c367123.

* fix(scripts): derive the test-wiring remedy from the workflow, and un-binary a gate script (#3319)

Two independent findings, both "a thing that looks fine because nobody can
see it".

1. check-test-wiring's 2b remedy line named `scripts/*.test.mjs` and
   `scripts/lib/*.test.mjs` as the directories the workflow glob catch-all
   reaches. That pair was correct when #3038 wrote it; the catch-all in
   test.yml has since grown `scripts/fixtures/*.test.mjs` and
   `scripts/docs/*.test.mjs`, and the sentence did not follow. A developer
   whose new test was flagged was being told two of the four directories that
   would have fixed it. The verdict was always right — only the advice drifted,
   which is why nothing caught it.

   The checker already computes the exact set (`testRunnerTargets` ->
   `globDirs`) to decide the verdict. It now returns that set and the message
   prints it, so the advice and the verdict read the same value and cannot
   disagree again. An empty set (no catch-all anywhere) prints its own remedy
   rather than an empty list. The header comment's directory list is likewise
   marked as not being the source of truth.

   Pinned by a regression case that gives the fixture a wider catch-all than
   the hard-coded pair and asserts every covered directory appears in the
   remedy; it reds against the old string.

2. scripts/moonshot/ci/check-report-numerals.mjs held two RAW NUL bytes, used
   as a composite-key separator in `${token}<NUL>backed`. That is the whole of
   what made git and grep classify the file as binary: `grep -c const` printed
   nothing while `grep -ac const` printed 200, so an ordinary search of this
   repo silently reported "not found" for anything in this file. Written as the
   `\0` escape instead, the string built at runtime is the same string — the
   source now differs from the old file only by that substitution, and the
   script's 1880-line output is byte-identical before and after.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(ifcx): export each entity's own IFC class, and keep IFC4.3 facility levels in the spatial tree (#3318)

* fix(ifcx): export each entity's own IFC class, and keep IFC4.3 facility levels in the spatial tree

The IFCX writer decoded an entity's typeEnum through a 26-row enum->class
table written by hand. IfcTypeEnum has 128 members and its numbering had
moved on since the table was typed, so the table was both incomplete and
SHIFTED against the enum it claimed to decode: 14 of its 26 rows named a
different class than the id actually holds. Running the real writer over an
entity table built from IfcTypeEnumToString:

  IfcStair               -> IfcRoof
  IfcMember              -> IfcPile
  IfcDistributionElement -> IfcOpeningElement
  IfcFlowSegment         -> (no class written)
  IfcRoad                -> (no class written)

That is a wrong value written into an exported file: bsi::ifc::class is the
node's IFC identity, and every reader takes the class from there and nowhere
else. 102 of the 128 ids had no row at all and lost the attribute entirely.
generatePath shares the lookup, so a GlobalId-less stair was also filed under
the path ifc:IfcRoof.7.

The class now comes from EntityTable.getTypeName, which resolves a type
override, then the enum, then the raw parsed class name — so IfcAirTerminal,
which the enum does not carry, keeps its own name too. IfcTypeEnumToString is
the fallback for structural table stubs with no getTypeName.

Second, the same shape in the same package: SPATIAL_TYPES, the set deciding
which classes are LEVELS of the IFCX spatial tree, held five names against the
shared authority's seventeen. It is also the stop condition in
collectElementIds, so a Site/Road/RoadPart/Wall tree did not merely lose its
facility levels — the site reported elements [road, roadPart, wall] and no
spatial children at all, and determineRelationshipType (a second hand-written
copy of the same list) called the Site->Road edge containment rather than
aggregation. Both call sites now derive from SPATIAL_STRUCTURE_TYPE_ENUMS in
@ifc-lite/data, the answer the parser and the viewer's hierarchy already use.

Tests derive their expectations from the enum and the authority rather than
restating a list, with an anti-vacuity floor on each, named required classes
so a regression names what it broke, and negative controls in both directions
(no class invented for an entity that has none; a physical element still
contained, not aggregated).

* chore(scripts): ratchet the ifcx writer.ts module-size row down to its new size

`packages/ifcx/src/writer.ts` shrank from 424 to 415 lines when its
hand-written enum->class table was replaced by a derivation, so the
recorded budget carried nine lines of headroom that no longer belongs to
anyone. `check-module-size` reports exactly that as a note and asks for
the row to be lowered; the allowlist lives under `scripts/`, which the
change that shrank the file could not reach.

Lowers the row to the measured 415 and re-pins the `packages/ifcx`
entry in `ALLOWLIST_DIGESTS` in the same commit, as the gate requires.
No other row moves, in either direction: the two remaining headroom
notes (`schema-converter.ts`, `parquet-tables.ts`) belong to `main` and
to files this branch does not touch, so tightening them here would put
an unrelated branch in the red for growth it is entitled to.

`node scripts/check-module-size.mjs` is green (309 rows, 0 new over
400) and `scripts/check-module-size.test.mjs` passes 29/29.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* feat(ci): a review of an older commit has not reviewed this PR (#3312) (#3317)

* feat(ci): a review of an older commit has not reviewed this PR (#3312)

Issue #3312's third ask, and the one nobody had built. louistrue: "A review
whose `commit_id` is not the PR head has not reviewed the PR." His example is
#3276 -- head `1305f778`, `CodeRabbit :: success / Review completed` sitting on
it, and CodeRabbit's newest review event naming `c26e453d`, three commits back,
the last of which is real code nothing reviewed. Parts 1 and 2 both pass there:
the lanes ran, and "Review completed" matches no no-verdict phrase. Verified by
running the pre-change gate over #3276's real reviews and statuses -- exit 0,
two green lines, no mention of staleness.

Nothing in the free text of a status links back to a review EVENT, so this adds
the one API that carries the linkage, `pulls/{N}/reviews`, paginated with
`--paginate --slurp` because the NEWEST review is on the LAST page and a partial
walk would compare an older `commit_id` and report a CURRENT PR as stale.

WHICH REVIEWS COUNT IS A POLICY CALL AND IS NOT SETTLED HERE. It is
`staleReviewPolicy`, validated like `reviewVerdictSeverity` -- an unrecognised
value is BAD_CONFIG, never a silent downgrade. Both obvious scopings are wrong
against this repository's data, measured 2026-08-26:

  - "ignore COMMENTED" would make the check a no-op. Every review event on
    #3276, #3288 and #3227 is COMMENTED -- CodeRabbit's, cursor[bot]'s,
    greptile's, codex's and the humans'. Not one APPROVED. It would drop #3276,
    the example the issue is written around.
  - "an author with no review at head is stale" would nag constantly. #3316,
    #3205 and #3290 carry ZERO review events, and #3316 and #3205 still carry
    `CodeRabbit :: success / Review completed`. Absence of a review is not
    evidence of staleness, and part 3 never reports it. That is a STATED HOLE:
    a reviewer that reviews without leaving a review event is invisible to a
    `commit_id` comparison, and no scoping fixes it.

So the shipped default `claimed-verdict` is the narrowest rule that still
catches #3276: configured author, AND its context reports success on the head,
AND its newest review names a different commit. The middle clause is what keeps
this off a reviewer that is merely still working. Over the 12 open PRs of
2026-08-26 it fires on #3288, #3227 and #2952 and stays SILENT on #3315, #3309
and #2931, whose newest CodeRabbit review names the head exactly.
`configured-authors` drops the context clause; `all-authors` drops the identity
scope too and is the one that flags a human APPROVED across a rebase.

Severity `warn`, same @unwired-by-design ruling as part 2: whether a bot has
re-reviewed the newest push is transient GitHub state, not a fact about the diff.

Fail-closed, each with its own reason and its own test: NO_HEAD_SHA, NO_REVIEWS,
REVIEWS_TRUNCATED, EMPTY_REVIEW_AUTHORS, UNREADABLE_COMMIT_ID,
UNREADABLE_REVIEW_ID, plus BAD_CONFIG on both new knobs. `--state-file` passes
`reviews` and `headSha` STRAIGHT THROUGH rather than defaulting them, because
that mode quietly supplying a value the real path computes (`timedOut: false`)
was this file's last defect.

20 mutations run against the guards; all 20 caught, and two of them were caught
only after adding tests the sweep proved were missing -- the eager config-read
validation of `staleReviewPolicy` and `reviewAuthors` was masked by the lib's
own, so both now assert over an input where the lazy path cannot be the one
speaking. Every guard restored by inverse edit, byte-identity proved with diff.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(ci): the staleness premise is false for CodeRabbit, so ship it off (#3312)

Fifth-round review of #3317. The `claimed-verdict` rule false-positives on 2 of
its 4 claimed fires, INCLUDING THE FLAGSHIP #3276, and the cause is a premise
this repo's primary reviewer does not honour: CODERABBIT SUBMITS NO REVIEW EVENT
AT ALL WHEN A RUN FINDS NOTHING ACTIONABLE. So "no review object naming the
head" is not "the head was not reviewed". Measured live 2026-08-26 on all four:

  #3276 head 1305f778 -- Review queued 14:09:52 -> in progress 14:09:55 ->
  success 14:12:27. A real 155 s cycle ON THE HEAD, and the walkthrough comment
  updated 14:12:25Z reads "No actionable comments were generated in the recent
  review" over "changes between c26e453d and 1305f778": the head, including the
  commit the rule called unreviewed. #3288 is identical (181 s, head named).
  BOTH FALSE.

  #3227 (14 s) and #2952 (9 s) are genuine -- their walkthroughs read "Reviews
  paused ... under active development", and CodeRabbit published
  `success / Review completed` regardless.

NOTHING IN THE STRUCTURED DATA SEPARATES THE TWO PAIRS. The status is
byte-identical across all four; CodeRabbit publishes no check RUN on any of
these heads, so there is no `conclusion` or `output.title` to read; and the
suggested narrowing -- "a completed review cycle on this head counts as review"
-- is not a narrowing but a deletion, because clause (b) already requires
`success` on the head and a `success` on the head IS a completed cycle, so it
silences #3227 and #2952 too. What is left is cycle DURATION, an unversioned
timing heuristic on a third party, and the reviewer's PROSE, which the config
rules out on purpose. It also contradicted this file's own stated hole: #3316
has success on its head, zero reviews, and is deliberately silent.

A rule that is wrong half the time cannot gate a PR and cannot be repaired with
a discriminator that does not exist, so the machinery, the three scopings and
the four worked examples all ship and `staleReviewPolicy` DEFAULTS TO `off`.
`off` is inert rather than merely silent -- it adjudicates nothing, so it
refuses nothing and does not pay for the paginated reviews walk -- and it NEVER
prints a pass: it prints `STALE_REVIEW not adjudicated` naming the knob.
#3227/#2952 stay catchable for whoever opts in. Verified live over #3276, #3288,
#3227, #2952, #3315, #3309, #2931 and #3316: `off` is silent on all eight, and
`claimed-verdict` still reproduces its 4-fire/4-silent table exactly.

A SUPPRESSED FINDING NO LONGER RENDERS AS A CLEAN PASS. With
`staleReviewSeverity: "fail"` and the shipped `reviewVerdictSeverity: "warn"`, a
rate-limited CodeRabbit with a stale review printed
`✅ No reviewer claims a verdict ... from a review of an older commit` and exited
0, while the same input under `configured-authors` printed `❌ STALE_REVIEW` and
exited 1: the `alreadyFlagged` dedup dropped the finding, so `stale.length === 0`
conflated "clean" with "suppressed" and the severity knob was inoperative.
`staleReviews` now returns the finding with `suppressedBy` set, and the caller
suppresses the SENTENCE, not the VERDICT -- one line naming what already
reported it, and the exit code still tracks the knob.

ORDERING IS `id` ALONE, and the old `(submitted_at, id)` was strictly worse: the
primary key was the one field that can be absent, so a review AT THE HEAD with
no timestamp sorted to `''`, lost to every dated review, and would have reported
a CURRENT PR as stale -- the finding the JSDoc promises is impossible. `id` is
always present (`UNREADABLE_REVIEW_ID` refuses otherwise) and removes the class
outright. `submitted_at` is still printed, no longer compared.

`fetchCheckRunDescriptions` now walks `--paginate --slurp` through
`flattenCheckRunPages`, which refuses a partial walk. It was not live (31 check
runs on the largest head measured, against a 100 page size) but the failure mode
was the bad one: under `claimed-verdict` a missing context is adjudicated by
SILENCE, so truncation was a false negative, not a failure.

And the gate's own unit tests now RUN. Neither test file was reached by any
workflow -- test.yml names its script tests one by one and this pair was never
added, and check-test-glob-coverage audits package globs, not `scripts/`.

10 mutations run against the guards; 10 of 10 caught, and the tenth only after
adding the WIRING test the sweep proved was missing: replacing
`flattenCheckRunPages(...)` with an inline `pages.flatMap(p => p.check_runs ?? [])`
survived the entire suite, because the helper's refusal was tested and its USE
was not. Every mutation restored by inverse edit, byte-identity asserted.

Refs #3312

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(cache): carry the raw IFC class name through a cache round-trip (#3320)

`EntityTable` has a `rawTypeName` string column so `getTypeName()` can name
a class the hand-maintained `IfcTypeEnum` does not cover. Diffing that enum
against `packages/data/src/ifc-schema/generated/entities-ifc4.ts`: 101 of the
157 concrete `IfcProduct` subtypes have no enum member (`IfcPump`, `IfcValve`,
`IfcAirTerminal`, `IfcBoiler`, `IfcSurfaceFeature`, ...). The cache writer
never serialized the column, and the reader kept its own copy of the accessor
closures with no fallback in `getTypeName`, so every such element came back
from a cache hit as 'Unknown' while the same model parsed from source named
it correctly.

The column is now written — format v15, appended after the type-range triples
so a v14 section's bytes are unchanged and the read is version-gated — and
`readEntities` builds its table through `entityTableFromColumns`, the same
constructor the parser path uses, rather than a second copy of the closures.
The duplicate is what let the fallback exist on one side only.

Tests: a named list of IFC4 classes split by enum membership, asserted in both
directions (in-enum classes are the negative control, out-of-enum classes are
the regression), with the pre-cache table pinned first so a failure can only be
the cache losing the name; and a v14 section with trailing sentinel bytes,
asserting the reader stops exactly at the old section boundary.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(viewer): name a server-loaded class the IfcTypeEnum does not cover (#3322)

`buildEntityTable` answered `getTypeName` from `IfcTypeEnum` alone while
already holding the real class string from the server (`cols.typeName[idx]`,
handed straight to `CompactEntityIndexBuilder.add`). Any class outside the
128-member enum — IfcPump, IfcChiller, IfcBorehole, most IFC4.3-only
classes — therefore reported 'Unknown', and the hierarchy's By-Type tab
collapsed all of them into a single "Unknown" row for server-parsed models.

This is the third EntityTable implementation to need the same fix:
`entityTableFromColumns` in packages/data already carries a `rawTypeName`
column for exactly this, and the cache-restored table is being fixed
separately. The fallback here is the same mechanism, not a fourth one — an
interned raw-name column, canonicalised with `IFC_ENTITY_NAMES` the way
`EntityTableBuilder.add` and the `setTypeOverride` below it already do.

The six string getters collapse onto one shared column accessor, which is
what keeps the file at its recorded module-size budget.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(data): derive IFC_ENTITY_NAMES from the schema instead of hand-maintaining it (#3323)

* fix(data): derive IFC_ENTITY_NAMES from the schema instead of hand-maintaining it

The map was an 880-entry literal whose header named a regenerator,
`scripts/generate-entity-names.ts`, that has never existed in this
repository. The only thing pinning it was a test comparing it against
`IfcTypeEnum` — a 128-member subset of the ~1160-entity schema — so
everything outside that subset could go missing unnoticed, and 282 entries
had: `IfcWallElementedCase`, `IfcSlabElementedCase`, `IfcBuildingElement`,
`IfcDoorStyle`, `IfcWindowStyle` and the whole `*StandardCase` family among
them. Every caller doing `IFC_ENTITY_NAMES[upper] ?? upper` fell through to
the raw UPPERCASE STEP keyword for those.

It is now built at load from `ifc-schema/generated/entities-*.ts`, which
`generate:ifc-schema` regenerates from the buildingSMART schema dumps, so a
schema bump carries the names along and there is no second list to fall
behind. `IfcSolidStratum`, `IfcVoidStratum` and `IfcWaterStratum` are
reachable through `IfcTypeEnum` but absent from those dumps, so they stay
listed by name.

`ifc-entity-names.schema-parity.test.ts` re-derives the expectation
independently and checks both directions plus a named required list, so a
derivation that starts dropping entities — an `abstract` filter, a schema
left out of the loop — fails instead of degrading display names silently.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* fix(data): emit IFC_ENTITY_NAMES at generate time instead of building it at load

Deriving the map at module load fixed the drift but kept all three
generated schema arrays alive in every bundle that touches a name lookup,
because a runtime loop over them is not something a bundler can
tree-shake. Measured with esbuild, minified, on an entry importing only
`EntityTableBuilder`: 49,405 bytes (12,932 gzipped) before the derivation,
681,999 (70,740) after. `@ifc-lite/data` is published, so a browser
consumer paid ~58 KB gzipped for a string map.

`scripts/emit-entity-names.ts` now writes the literal from the same
`entities-*.ts` tables, chained onto `generate:ifc-schema` so a schema
bump regenerates both in one command. The emitted map is identical to
what the load-time build produced — same 1162 keys, same values, same
insertion order — and the entry now costs 63,283 bytes (16,780 gzipped),
so the 282 recovered names cost ~3.8 KB gzipped rather than ~58 KB.

A committed artefact introduces one new failure mode, staleness, and
`ifc-entity-names.schema-parity.test.ts` is what closes it: it re-derives
the expectation from `entities-*.ts` and checks both directions, so an
`entity-names.ts` left behind by a schema bump fails there. Verified by
mutation — adding an entity to `entities-ifc4.ts` without regenerating
fails `schema → map`; dropping a key and inventing one fails four of the
five tests. The emitter refuses to write when a source array is empty,
which is the load-time module's silent-degradation case: it returned a
3-entry map without throwing.

The three `*STRATUM` names remain hand-added, in the emitter, with the
comment explaining that they are `IfcTypeEnum`-reachable but absent from
the buildingSMART dumps.

Changeset prose corrected on two points a reviewer raised: some of the
282 additions are defined types rather than entities (`IfcLengthMeasure`,
`IfcLabel`, `IfcBoolean`, `IfcGloballyUniqueId`), and `IfcWallStandardCase`
was already listed, so "the whole *StandardCase family" was overstated.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* chore(scripts): drop the stale ifc-entity-names module-size row

`packages/data/src/ifc-entity-names.ts` is now 31 lines — the map it used to
carry inline is emitted into `src/ifc-schema/generated/entity-names.ts`, which
the ratchet excludes as generated. Its 907-line row is therefore pure slack,
and `check-module-size.mjs` prints a note asking for it to be deleted.

Deleted, with the `packages/data` scope re-pinned in `ALLOWLIST_DIGESTS` in the
same commit. Only that scope moved; no budget was raised and no row added —
309 rows to 308, one removal, verified against `upstream/main`.

Claude-Session: https://claude.ai/code/session_01QPHChk3Ve9N519A4kY7436

* Adding anonymizer-export for debug

* fix(export): close the anonymized-export review findings (#3309)

Addresses every finding raised in the review of #3309.

Correctness and privacy:
- The CLI no longer copies the output path into the STEP header's
  FILE_NAME, so `--out <project>.ifc` cannot reintroduce the name the
  export exists to remove. It falls through to the neutral default.
- IfcSite/IfcBuilding georeferencing and address slots are blanked with
  `$` instead of an empty string. RefLatitude/RefLongitude are a LIST OF
  INTEGER, RefElevation a REAL, and the two address slots are entity
  references, so `''` left the file unable to round-trip a strict reader.
- IfcPerson keeps FamilyName = 'Anonymous' rather than clearing every
  slot, which violated the IdentifiablePersonName WHERE rule and let a
  validator reject the file a bug report is meant to carry.
- Attribute slots now resolve against the source model's own schema.
  Fixing the read side alone was not enough: `setAttribute` re-resolves
  the name to a slot independently at serialize time against the pinned
  IFC4 order, so on an IFC2X3 model the scrub wrote to the wrong slots
  and left the real value untouched. The scrub writes positionally with
  the already-resolved index.

Viewer:
- Only the trigger-less host instance answers the store flag. Both the
  ViewerLayout mount and the toolbar-registered one used to open
  together, each running preview isolation and each restoring shared
  visibility state, which could strand the viewer in the temporary
  isolation.
- The section-header checkbox writes `indeterminate` from an effect
  rather than a ref callback, which React does not re-invoke on
  re-render, so the mixed state went stale until a row remounted.

CLI surface:
- Relationship flags use exact IFC EXPRESS names, and IfcRelAggregates
  and IfcRelNests are no longer collapsed into one switch.

Tests and docs:
- Entity-presence assertions parse the exported model instead of
  matching serialized text, where 'IFCWALL' also matched IFCWALLTYPE and
  IFCWALLSTANDARDCASE in both directions. The relationship tests assert
  the relationship is gone rather than that a pseudonymized name is
  absent.
- New coverage: an unselected spatial root is absent with none of its
  values reachable and a selected one is scrubbed; the serialized
  IFCSITE/IFCBUILDING lines; the dialog host gating; IFC2X3 slot
  resolution.
- The CLI guide no longer promises a `--keep-*` flag for scrubs that
  have no opt-out, and the export README's retained-field list matches
  the code.

Module-size ratchet: the schema-derived type-set machinery moves out of
reference-collector.ts into entity-type-sets.ts, re-exported so no
caller and no public surface changes; the four viewer files come back
under budget by compressing prose. No budget was raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Louis Trümpler <78563314+louistrue@users.noreply.github.com>
Co-authored-by: Petru Conduraru <petru@bimvoice.com>
Co-authored-by: Yuri Isachenkov <69924139+Blogbotana@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

2 participants