Project-specific gotchas and guardrails: the things that bite you here and that you can't infer from the code. Generic good practice is assumed, not repeated. This file is the single source of truth; CLAUDE.md and .cursorrules just point here.
Browser-first IFC toolkit: a WebGPU web viewer plus a headless CLI/MCP/server. No first-party desktop app. Domain logic (decode, geometry, styling, export) lives in Rust crates under rust/* and is the source of truth for the server, CLI, SDK, and wasm; TypeScript packages under packages/* and apps under apps/* consume it (TS mostly does GPU upload and UI). Architecture docs: docs/architecture/overview.md.
- Install:
corepack enable && pnpm install(Node 22.x,pnpm@10.8.1pinned viapackageManager). - Build:
pnpm build(turbo). WASM:pnpm build:wasm(needs Rust nightly +wasm-pack);pnpm build:wasm:fetchpulls the prebuilt wasm from npm when Rust is unavailable (e.g. Windows without WSL). - Typecheck:
pnpm typecheck(turbo, wasm-free: no Rust toolchain needed). This covers test sources too, which for a long time it did not: the roottsconfig.jsonexcludes**/*.test.tsso tests never reachdist/, everypackages/*config inherits that throughtsconfig.packages.json, andexcludefiltersinclude— so a package's"include": ["src/**/*"]cannot pull its own tests back and 652 of 960 test files were in no typecheck program at all (#2457). Since vitest andtsx --testtranspile per file without checking types, an unchecked test still runs while everything it asserts at type level (expectTypeOf,@ts-expect-error, a typed fixture) is silently unverified. Each package'stypecheckscript therefore runsscripts/typecheck-tests.mjs, which derives a no-emit test program from that package's own tsconfig and lists the test files underfiles— unlikeinclude,filesis not filtered byexclude. The generated<pkg>/tsconfig.tests.jsonis gitignored (rebuilt every run); the shared emit-off overrides live intsconfig.tests.base.json. Do not "fix" a test-only type error by putting tests back into a package's emit tsconfig — that ships them todist/. - Test:
pnpm test(turbo, TS);cargo test --workspace(Rust, notcargo check);pnpm test:wasm-contract(real wasm boundary);pnpm test:collab(collab pair). - Always run typecheck/test through the root
pnpm typecheck/pnpm test(turbo), never a package-local script (pnpm --filter <pkg> typecheck,cd packages/<pkg> && tsc --noEmit/vitest run) or an editor's TS server in isolation.turbo.jsonmakestypecheck/testdepend on^build, but that dependency graph only fires through turbo; a package-local script or IDE process resolves workspace siblings straight off theirdist/(roottsconfig.jsonpath-maps most packages todist/*.d.ts; the rest, e.g.@ifc-lite/pointcloud, resolve viapackage.json#types→dist/index.d.ts). An unbuilt or stale siblingdist/then throws "Cannot find module" / missing-export errors that name real files and symbols but describe no bug in the source — rebuild (pnpm build) before trusting a bypass-turbo result that disagrees withpnpm typecheck/pnpm test. - Dev viewer:
pnpm dev(full monorepo build, then--filter viewer dev; needs a built@ifc-lite/wasm). - Fixtures:
pnpm fixtures(tests skip when absent). Dead code:pnpm knip(TS) /cargo test --workspace(Rust). - Publish:
pnpm changeset; refresh the API-surface snapshot withpnpm api-surface:update.
House rules (self-police the TypeScript ones — the Rust ones are CI-enforced, see below): no as any / @ts-ignore (fix the types or add a .d.ts); no silent catch {} (log or rethrow); split production modules over ~400 non-generated lines; new packages/features ship tests; package-specific deps go in the consuming package, never root. pnpm lint runs oxlint against apps, packages and scripts (3,008 files, ~1s wall including pnpm's own startup; oxlint's own pass is ~60ms warm) with .oxlintrc.json, and the CI "Lint" job runs that — but only its ERROR tier is a gate, so the house rules above are still self-policed. Errors are the curated list in .oxlintrc.json: rules whose every hit in this repo was a real defect (no-unreachable, no-useless-catch, no-debugger, no-dupe-keys, use-isnan, valid-typeof, no-loss-of-precision and ~40 more of that kind). Everything else is a warning — visible, not blocking — and that includes about 35 rules from oxlint's own defaults, which "categories": {} does NOT switch off: 98 rules run in total, and a rule you never see in the config file is running at warn severity. no-unused-vars is the bulk of it, at ~353 of the ~450 warnings, nearly all dead imports; clean those up in files you are already touching rather than in a sweep. oxlint honours the existing // eslint-disable-next-line <rule> comments (~190 of them predate any linter and now do something), so silence a deliberate violation the same way, with the directive on the line DIRECTLY above the offending one and a reason above that. tsc --strict still does not flag as any/@ts-ignore/catch {}, and no lint rule here does either. Before this, pnpm lint was pnpm -r lint with no package defining a lint script: it printed "None of the selected packages has a lint script" and exited 0, so the CI job spent ~30s and verified nothing. pnpm lint also validates pending changesets (scripts/check-changesets.mjs), so a changeset naming something outside the workspace — the private repo root is the easy mistake — fails on the PR. Release remains the backstop, but it used to be the FIRST to know: nothing else on a PR reads changesets, so a bad name landed on main and blocked every release until someone noticed. scripts/check-lint-ran.mjs lints each of apps, packages and scripts separately and fails if any one of them falls under its own floor — a single combined floor could not see one target stop matching, since the other two would carry the count past it — and .oxlintrc.json is in the frontend path filter so a PR that edits only the config cannot skip the job that reads it. The size rule binds production modules only — test files are exempt from it, which matches rather than contradicts the enforced Rust side, where the module_size_ratchet below already exempts test/example/bench files — but a long test file should still be split where it has a genuine logical seam (two units with two different failure stories), never merely to hit a line count. Rust is not self-policed: the "Rust tests" job runs cargo clippy --workspace --exclude ifc-lite-wasm --all-targets -- -D warnings, so a warning-only defect (an unused import, a needless borrow) is a hard CI failure even when cargo test --workspace --no-fail-fast is green locally — cargo test does not fail on warnings. Run that exact command before pushing Rust — including the --exclude ifc-lite-wasm (that crate's tests target wasm32, and a plain --workspace clippy additionally reports pre-existing errors there, so it looks broken while telling you nothing about the gate). The ~400-line module rule is likewise enforced for Rust, by the module_size_ratchet test (rust/processing/tests/): a new non-generated, non-test .rs over 400 lines fails the build unless it has a row in module_size_allowlist.txt (test/example/bench files are exempt, and an allowlisted file may not grow past its recorded budget). Prefer splitting to allowlisting — for a module whose bulk is #[cfg(test)], the house pattern is a sibling <name>_tests.rs included via #[cfg(test)] #[path = "<name>_tests.rs"] mod tests; (see stream_meta.rs). The real CI gates are typecheck, tests, test-wiring (scripts/check-test-wiring.mjs), test-typecheck coverage (pnpm check:test-typecheck, run as the tail of pnpm typecheck), clippy, the module-size ratchet, the wasm-type-sync check, and the API-surface check. The last of those pairs with test-wiring: that one fails when a package with tests has no test script, this one fails when a package with tests has no typecheck script or when a test file on disk is in no typecheck program — including the .ts/.tsx basename collision that silently drops one of the pair from the program (useCompare.test.ts + useCompare.test.tsx did exactly that).
- User-facing APIs/exports/scripts use exact IFC EXPRESS names: PascalCase attributes (
GlobalId,Name,ObjectType), full relationship names (IfcRelAggregates, notAggregates). Never invent aliases. - STEP type names are stored UPPERCASE; render via
store.entities.getTypeName(id)to getIfcPascalCase. - The rule binds where an EXPRESS name exists. A derived collection with no EXPRESS counterpart is named for what it holds, and renaming it to an
IfcRel*type would be a fresh inaccuracy:EntityRelationshipsData'svoids/fills/groups/connectionshold the related objects, not theIfcRel*entities, so they keep their names (#2422 — reasoning at the type, don't re-open). Two pre-existing camelCase surfaces are likewise frozen, not endorsed:withAliases(packages/sandbox) emits every entity attribute under both spellings, PascalCase canonical, camelCase kept because sandbox scripts have no version channel; and the built-in templates' non-entity types (BimPropertySet.name,BimModelInfo.name, …) are plain data-shape fields. New surface still gets the EXPRESS name, once.
- One canonical load path: every model, primary and federated, any format, loads via
useIfcLoader.loadFile(file, target);useIfcFederation.addModelis a thin wrapper. Never add a second load/ingest pipeline: a federated-only path that drifts fromloadFilesilently skips load-time features. - Resolve selections/IDs through
FederationRegistry(toGlobalId/fromGlobalId/getModelForGlobalId), never ad-hoc math; honor the single-model fallbackglobalId === expressId. Verify behaviour atmodels.sizeof 1 and N.- Resolving a globalId back to a model+expressId is the exception: use
modelSlice.resolveGlobalIdFromModels(viaresolveEntityRef, the canonical caller), not the registry. Two reasons, and the first applies always, not just in a narrow window: the registry knows nothing about overlay-allocated ids, so an entity created throughStoreEditorfails to resolve through it entirely —modelSlice.ts's second pass consultsmutationViewsprecisely for that. It is also store-backed rather than a singleton, so it cannot be stale right after a model swap.toGlobalIdand the registry rule above still stand for everything else; do not hand-roll the offset arithmetic as a third copy.
- Resolving a globalId back to a model+expressId is the exception: use
extractEntityAttributesOnDemandre-parses the source buffer, so never call it in loops; use cachedEntityNodegetters.
- Free every WASM handle (
MeshCollection,MeshDataJs, pre-pass cache) deterministically: wrap pre-pass + job batches intry/finallyso.free()/clearPrePassCache()run on early return, on throw, and when an async generator is abandoned (.return()runsfinally). Getters copy into JS arrays, so.free()right after extraction is safe; don't deep-copy already-extracted meshes. - Coordinates: IFC is Z-up, the viewer is Y-up (converted during mesh parsing).
/api/create(theifc-lite viewCLI REST endpoint) expects IFC Z-up[x, y, z]. - CSG: the pure-Rust exact kernel (
rust/geometry/src/kernel/) is the only CSG kernel on every target; Manifold C++ and the BSP port are deleted, and there is no kernel selection, build-time or runtime. Diagnostics:csg::take_csg_census(),GeometryRouter::take_csg_failures()/take_host_opening_diagnostics(). - All per-element mesh production converges in
produce_element_meshes(rust/processing/src/element.rs), called by both the native orchestrator and the browser batch (processGeometryBatch). Any mesh-shape change (void cut, submesh split, texturing, type geometry) lands there once; a fix applied to only one pipeline silently diverges (the bug class behind #858/#913/#957/#961/#1071). The only sanctioned behavioural fork isTypeGeometryMode. - Workspace
[profile.release]setspanic = 'abort'; harnesses/examples that needcatch_unwind(per-element panic isolation) must build with--profile server-release(panic=unwind). - Driving
GeometryRouterdirectly in examples/harnesses skips the RTC rebase: georeferenced models (>10 km coords, e.g. ISSUE_098 at ~5,000 km) produce f32-fabricated geometry "failures"; bucket by coordinate magnitude or rebase before blaming the kernel. - Colour and coordinate resolution is canonical Rust shared by the server (
process_geometry) and viewer (process_geometry_batch) so they can't drift; don't re-fork it. Single homes:default_color_for_type,resolve_submesh_color,extract_surface_style_colors(the IfcSurfaceStyle to Rendering to Colour leaf; SurfaceColour is the apparent colour, a distinct DiffuseColour IfcColourRgb is onlyshading_color, per #859/#871), and the indexed-colour resolvers, all inifc_lite_processing::style;rotation_angle_about_z(site/building rotation, offresolve_scaled_placement) inifc_lite_geometry.styling_parityRust tests fail the build if a duplicateget_default_color*table or a per-pipelineextract_color_from_rendering/extract_color_rgbreappears. New type default: editdefault_color_for_typeand extend the mesh fixture. Sanctioned exceptions: the 2D drafting palette insection-2d-overlay.ts(PARITY-ALLOW) and standalone debug tools underrust/geometry/examples/(can't reach the downstream crate). (#913, #996, #997) - Cross-platform determinism is enforced weekly by
.github/workflows/determinism.yml(freeubuntu-24.04-armrunner, alsoworkflow_dispatch-able before kernel-sensitive merges): it re-runsexact_predicate_determinismandgeometry_correctness_harnesson arm64 against the committed x86_64-generated insta snapshots; a snapshot mismatch there means platform-dependent geometry (a real bug, not flake). It also pins the mesh-output manifestsrust/processing/tests/manifests/mesh_determinism.json(x86_64/arm64) andmesh_determinism.wasm32.json(identical except the documented libm-trig gap), so a legitimate geometry-output change must re-pin both manifests, not just the snapshots (#1492, seedocs/architecture/mesh-determinism.md). - Geometry-affecting PRs also run
.github/workflows/ifcopenshell-parity.yml(path-gated): a per-PR quick lane diffs ifc-lite against committed reference dumps intools/ifcopenshell_reference/, plus a nightly full lane on the pinned engine. It is non-blocking (not on the required needs-list), so a legitimate output change means regenerating the committed reference in the same PR (#1519).
- Perf-sensitive paths:
rust/geometry,rust/processing,rust/core,packages/geometry,packages/wasm,scripts/build-wasm.sh. A change under these must carry a perf verdict in the PR. - Read the ledger before optimizing:
scripts/perf/README.md(lever ledger) lists shipped wins, dead ends, and un-shipped levers. Do not re-spike a listed dead end without a genuinely new mechanism — isolated microbench wins have been refuted end-to-end three times (#1429 threaded WASM, Manifold, #1445 shared-index). A perf claim is only real as an end-to-end worker-pool number, never a kernel-only microbench. - Measure base-vs-branch, never vs the committed baseline. Run
scripts/perf/probe.sh <fixture> --iters 5 --jsonon both your base and your branch, and paste the parse/geometry/total deltas plus a byte-identity statement (mesh/triangle counts unchanged, or an FNV hash if outputs may legitimately differ). Default fixture:tests/models/ara3d/AC20-FZK-Haus.ifc; for CSG/void/mesher changes also runtests/models/ara3d/ISSUE_129_N1540_17_EXE_MOD_448200_02_09_11SMC_IGC_V17.ifc, and when feasible a heavy CSG model (Holter/ISSUE_053) since CI never touches that class and that is where every shipped regression has lived. - Never cite a local
pnpm benchmark:checkagainsttests/benchmark/baseline.jsonas evidence — the baseline is CI-recorded (SwiftShader), so faster local hardware makes it vacuously green. Use the scratch-baseline flow intests/benchmark/README.md. - Guard against contaminated measurement: confirm
packages/wasm/pkg/ifc-lite_bg.wasmis newer than yourrust/edits before trusting a WASM number (stale artifacts and turbo overwritingpkg/have each caused phantom regression hunts); run each A/B interleaved on an otherwise-idle machine; in the browser, load the target file first in a fresh tab (the 7th load in a session runs 2-4x slower from memory pressure, not code). - Record the verdict in the repo, not chat. When a perf PR closes with a measured result, record the verdict and lesson (what won/lost and why, so the next agent doesn't re-walk it) in the
scripts/perf/README.mdledger in the same PR. This is distinct from the numeric baseline, which lives only in the generatedperf-numbersregion ofdocs/guide/performance.md(see Documentation upkeep) — the ledger carries the narrative, the docs carry the numbers; don't duplicate a benchmark figure into the ledger prose.
- Don't hand-edit
packages/wasm/pkg/*: change the Rust crates and regenerate withscripts/build-wasm.sh. The wasm runtime (.wasm/.js) is gitignored and rebuilt on every Rust-capable host; the type surfacepkg/ifc-lite.d.tsis committed (force-added past the wasm-packpkg/.gitignore*) for the wasm-free typecheck lane (#952), sopnpm typecheckruns without the Rust toolchain (tsconfig.jsonpath-maps@ifc-lite/wasmtopkg/ifc-lite.d.ts, andbuild-wasm.shsoft-skips when wasm-pack is absent).build-wasm.shstrips the platform-variant__wasm_bindgen_func_elem_*trampoline indices from the.d.tsso the sync gate can exact-diff across macOS/Linux, so only ever regenerate it via that script. When a Rust public-API change alters the bindings, re-runbuild-wasm.shand commit the regeneratedpkg/ifc-lite.d.ts; CI (test.yml, "Verify committed wasm types are in sync") fails if it drifts.build-wasm.shalso rewritespkg/README.mdandpkg/package.json, which is why neither is tracked — they fall under thepkg/.gitignore*like the runtime, so the churn can no longer reach a commit. Nothing reads them: the workspace member ispackages/wasm/package.json(a different package name, and itsfilesships only the runtime plus the.d.ts), and the package README the docs gate checks ispackages/wasm/README.md. - The wasm build needs only the pinned Rust nightly +
wasm-pack(the Manifold C++ / LLVM-20 cross-toolchain was removed at M9; the bundle is pure Rust). That toolchain is single-sourced in the.github/actions/setup-wasm-buildcomposite action (used bytest,release,sdk-canary,benchmark,determinism): change it there once, not per-workflow.scripts/build-wasm.shitself runs directly intest,sdk-canary, andbenchmark;releaseinvokes it viapnpm build. - ifc-lite ships a web viewer plus a headless CLI/MCP/server only; there is no first-party desktop app (removed: the
apps/desktopTauri shell and the viewer override-contract are gone). The desktop capability lives in@ifc-lite/geometry(IPlatformBridge/NativeBridge/isTauri(),@tauri-apps/apioptional dep) for third parties building their own Tauri shell; keep it web-pure (it must never be imported on the web path, and is lazy-loaded only underisTauri()). - The Rust tree is intentionally not rustfmt-clean and no CI runs fmt: never run a repo-wide
cargo fmt(it produces a 100+ file reformat blast that buries the real diff). Format only the lines you touch. - Beyond the server, CLI, SDK, and wasm, a fifth Rust consumer exists: the PyO3 wheel
ifclite_geom(rust/python, abi3-py39). Public-API changes inrust/core,rust/processing, orrust/geometrycan break it;.github/workflows/python-wheels.ymlis path-gated to those crates and publishes on anifclite-geom-v*tag. - Adding or changing a sandbox bridge method (
packages/sandbox/src/bridge-*.ts, i.e.NAMESPACE_SCHEMAS) also changes the generated ambientbimtype surface,apps/viewer/src/lib/scripts/templates/bim-globals.d.ts(the types the built-in template scripts under that folder are written against). Runpnpm generate:bim-globalsand commit it; CI (node scripts/generate-bim-globals.mjs --check, node-tests job) fails on drift. The editor completions and the LLM system prompt readNAMESPACE_SCHEMASlive, so the.d.tsis the only consumer that can rot — and it did: it never carriedbim.clash(#891 → #2418) and lost twocreateparameters at #598, because the generator had stopped running and the file got hand-edited instead. Never hand-edit it; fix the schema and regenerate. - A
tsReturn/tsParamTypesmay NAME a type instead of spelling its shape inline (Promise<BimClash.ClashResult>). Those declarations are extracted from their defining package byscripts/generate-bim-globals.mjs(DERIVED_TYPE_SOURCES/DERIVED_TYPE_ROOTS) — never transcribe another package's types into the generator, which would be a hand-maintained copy in the one script whose purpose is to stop copies rotting (#2422). Referencing a type the generator cannot resolve is a hard error telling you which list to add the file to.pnpm check:templates(node-tests job) typechecks the built-in templates against the generated surface and is what catches atsReturnnaming a type that is never emitted; its tsconfig keepsskipLibCheck: falseon purpose, because the generated.d.tsis the subject of that check rather than a dependency of it. Cargo.lockis committed (app crates need reproducibility; an upstream yank broke CI once). Refresh withcargo update -p <crate>, never by deleting it.- Pushing a PR branch does not spin up a Vercel preview: preview builds on the Rust+WASM projects (
ifc-liteviewer,ifc-lite-viewer-embed) are turned off via each project's Ignored Build Step to save build minutes (mainstill deploys to production). To preview a branch, trigger it on demand:vercel link(once per app dir;.vercelis gitignored) thenvercel deploy(--prodto promote);vercel build && vercel deploy --prebuiltboth skips the remote WASM compile and is never gated by the Ignored Build Step. Per-project setup and rationale:scripts/README-vercel-cost.mdsection 3d.
- A public API or CLI flag change updates the matching guide in the same PR.
docs/guide/*.md,docs/tutorials/*.md, and the README carry livets/typescriptsnippets that CI typechecks against each package'ssrc(pnpm docs:check-samples), so a renamed export or changed signature fails the build until the snippet is fixed. Snippets use shared ambient globals (scripts/docs/doc-samples-globals.d.ts); a snippet that is deliberately illustrative pseudo-code opts out with<!-- docs-check: skip -->on the line directly above the fence (use sparingly — prefer fixing it). - Perf and size numbers live only inside the generated
perf-numbersregion ofdocs/guide/performance.md, stamped fromtests/benchmark/baseline.json. Don't hand-write a benchmark number into prose; refresh the region withpnpm docs:generateafter recording a new baseline. - The package table in
docs/api/typescript.mdand the CLI command table indocs/guide/cli.mdare generated (<!-- BEGIN/END GENERATED -->regions). Edit the source (eachpackage.jsondescription, the CLIHELPtext), then runpnpm docs:generate; CI (pnpm docs:check-generated) fails if a region is stale. - Every new published package ships a
README.md(it is the npm landing page); CI enforces this withpnpm docs:check-readmes.
- Changes to published
packages/*needpnpm changeset(never hand-edit versions orCHANGELOG.md). Bump level = biggest API change: removing/renaming an export ismajor(>=1.0 pkg) orminor(0.x), neverpatchwhen the surface shrank. - Only re-export from a package's
index.tswhat has a real consumer: an unused public export is permanent semver liability. - The exported surface of every published package is snapshotted in
scripts/api-surface.jsonand CI-enforced (scripts/check-api-surface.mjs): when you intentionally add/remove/rename an export, runpnpm api-surface:updateand commit the snapshot alongside the changeset.
- Supersede means delete: replace a path, remove the old one in the same PR. No "legacy"/"fallback"/"just-in-case" path; if one must stay, gate it behind
// TODO(remove-by: <cond>, <owner>)and a tracking issue. - Delete dead code with the change that orphans it. When renaming/removing a public symbol, grep the whole repo (
docs/,examples/,scripts/,*.md) and fix every reference in the same PR. - Prove removals: TS with
pnpm knip(on-demand, not a CI gate); Rust withcargo test --workspace, notcargo check(check skips#[cfg(test)], so a test-only reference to a deleted fn slips through). Intentionally-unused Rust items need#[allow(dead_code)]and a why.
- Not committed (no LFS): catalogued in
tests/models/manifest.json, fetched viapnpm fixtures. Tests must skip (never throw/panic) when a fixture is absent; point topnpm fixturesin the skip message. Add one: drop undertests/models/<group>/, runpnpm fixtures:manifest, thenpnpm fixtures:upload; commit only the manifest. CI runspnpm fixturesbefore tests.
Entity references come from the file, so their shape is exporter- and
attacker-controlled. An unbounded recursive walk over one self-referential
entity dies by SIGABRT, which is not a catchable panic: nothing upstream turns
it into a load error, and in the wasm geometry worker it takes down the
instance. #2866 found seven such sites.
The three mechanisms are not interchangeable, and each alone leaves a hole:
- a depth cap bounds one path's LENGTH, not its breadth.
kitems each leading back into a cycle costO(k^depth), turning an abort into a hang (measured 7.21s at k=3). A hang is worse: nothing reports it. - a visited set bounds cycles and revisits. While the walk still recurses it does NOT bound a long acyclic chain: every insert succeeds, the set never fires, and the stack still overflows.
- a work budget bounds acyclic DAG fan-out, which neither of the others
sees. A DAG where every branch succeeds never errors and never repeats an id;
it just emits
2^levelsoutputs.
Choose the visited set's SCOPE by what the walk returns:
- global / memoising when the result is a pure function of the id (a colour is determined by item id plus style map). Safe and strictly stronger: it kills fan-out outright.
- path-scoped (insert on the way in, remove on the way out) when output ACCUMULATES. A boolean operand tree is a DAG and geometry accumulates, so the same node down two branches is two real pieces of geometry. A global set silently drops the second: missing geometry, not a cycle guard, and no termination test notices.
Prefer making the walk iterative over adding a cap: with no stack to consume
there is nothing left for a cap to protect, and the visited set becomes
sufficient alone. A cap tight enough to stop a cycle usually rejects legitimate
input — #960 records Revit FirstOperand chains 42 nodes deep.
Reuse the shared bound where the chain is shared: ifc_lite_core::limits.
A guard that both ACTS and REPORTS has a two-part contract — bound the work, and report that you bounded it. Mutate the halves separately; they fail differently (a missing bound hangs, a missing report returns a truncated success).
scripts/check-refwalk-guards.mjs (CI) enforces the presence of a guard, not
its choice: it fails when a self- or mutually-recursive function, or a
cursor-chasing loop, reaches decode_by_id/resolve_ref/resolve_ref_list
under rust/geometry/src, rust/processing/src or rust/wasm-bindings/src
with no visited set, path stack or depth cap in scope. Five of the six #2866
fixes are detected at their parent commit and clear at the fix; the sixth
(#2870) spans two files and is missed, which is the documented blind spot. Which
guard is right stays a review question — everything above still applies. The
allowlist is scripts/refwalk-guard-allowlist.txt and is empty.
- A new test must assert behavior through a real fixture or a stated invariant. Don't write: set-state-then-read-it-back store tests, tests that assert a mock's return value (they test the mock), constructor/setter tautologies, or byte-for-byte output pinning unless the byte layout IS the compatibility contract (e.g. signed bundles). Regression tests cite the issue/PR number in the test name or a comment.
- Every package with test files needs a
testscript in its package.json orturbo testsilently skips it;scripts/check-test-wiring.mjs(CI) enforces this. Packages use vitest OR node:test viatsx --test; match the package's existing convention, never mix within a package. - A new gate under
scripts/must be INVOKED, and a package.json entry is not invocation. A guard nothing runs is the same absence as a guard that finds nothing, and it is invisible in exactly the same way: #3062 shipped a gate script and its test with no workflow step, nopackage.jsonscript and no turbo task, and nothing flagged it.scripts/check-test-wiring.mjsnow also auditsscripts/— everycheck-*/verify-*file at any depth and in.mjs,.jsor.cjs(verify-npm-publish.jsandcheck-benchmark-regression.jsare real gates, andscripts/ci/is exactly where the advice "move it to a covered directory" puts a file) — and accepts exactly the wiring this repo actually uses: a.github/workflows/step runningnode scripts/<gate>.mjs(the common case — see thenode-testsjob), a rootpackage.jsonscript a workflow reaches throughpnpm <name>, possibly transitively (check-changesets.mjs←lint← the Lint job), a workspace package's script reached by a turbo task (check-tla-chunk-await.mjsis the viewerbuildscript's tail, deliberately, so Vercel runs it too and not only CI), or a file that a script already reached imports or spawns (below the top level averify-*name is as often a module as a gate:moonshot/diff-spike/verify-common.mjsis imported by the workflow-wiredverify-trajectory.mjs, and a file a running gate runs does run). A rootpackage.jsonentry no workflow ever calls executes exactly as often as no entry at all and is rejected.scripts/*.test.mjsis audited separately from the gate itself — thenode --test scripts/*.test.mjs scripts/lib/*.test.mjscatch-all covers those two directories and no others (a shell glob has no**), and a gate whose test runs while its script never executes is still the #3062 failure. A script that genuinely should not run in CI declares@unwired-by-design <reason>in its own header and is then listed in the checker's OK output rather than hidden; today that ischeck-generated.mjs(a pre-push aggregator of gates CI already runs),check-git-lfs.mjs(reports on the developer's own clone),check-whole-state-reset.mjs(an unadopted heuristic, issue #2802) andmoonshot/b45-m1-midterm/verify-worker.mjs(a worker of a hand-run benchmark). The marker is a blanket escape — nothing judges the reason's quality — so its only safeguard is that every declaration is printed and lives in the diff. The check is lexical and does not evaluate workflow expressions: a gate whose only step sits in a job withif: falsestill counts as wired.audit-*.mjsreports and does not gate, so it is not demanded. - Never assert on a source file's text. A test that reads
Thing.tsxand greps it certifies a string exists, not that the code works:SearchModal.filter.wiring.test.tsxasserted the whole body ofhandleRowClickand stayed 5/5 green whenonRowClick={handleRowClick}was replaced withonRowClick={() => {}}— defect #2396 verbatim.scripts/check-source-text-assertions.mjs(CI) blocks new ones; the remaining list is inscripts/source-text-assertion-allowlist.txtand only ratchets down (#2434). - Testing a viewer component. It is mountable — including one that reads
useViewerStore, which is a module-level Zustand store you seed withsetState. Three test files claimed otherwise for months; the real blockers were two Vite-isms, fixed once inapps/viewer/src/test/. The recipe:import '@/test/setup-dom.js'; // MUST be first: registers happy-dom import { installLayout } from '@/test/dom-layout.js'; installLayout(); // only if it virtualizes or measures import { render, click, advance, cleanup } from '@/test/render.js'; import { fixtureModel, fixtureModels } from '@/test/store-fixture.js'; useViewerStore.setState({ ...fixtureModels(fixtureModel('m', { idOffset: 1_000_000 })) }); const ui = render(<YourPanel />); click(/* what a user clicks */); assert.equal(useViewerStore.getState().selectedEntityId, expected);
~icons/*(unplugin-icons virtual modules) andimport.meta.envare handled bysrc/test/vite-module-hooks.mjs, registered from the--importflag in the viewer'stestscript — it cannot be registered from inside a test file, because module resolution finishes before any module body runs.installLayout()also makesResizeObserverdeliver an entry: happy-dom ships the class but never fires it, which is why a virtualized list otherwise renders zero rows. Four more surfaces were said to be unmountable and are not (#2434):MainToolbar,RibbonToolbarand its tabs mount as-is;FileTabneeds itsfileCommandsprop;PropertiesPaneland anything reading aModelQueryneeds a REAL store (new IfcParser().parseColumnar(...)over a few inline STEP lines — seetools/measure-parity.test.tsx), not a cast stub;ViewportContainerneeds a stubbednavigator.gpuplus one loaded model, or it renders the "WebGPU Not Available" screen. A Radix dropdown opens onpointerdown(thenclick) and portals its items ontodocument.body, so query them there, not inside the container.RibbonLargeButtonsetsaria-label={tooltip ?? label}, so match its visible text when it has a tooltip. Assert on the OUTPUT, not on the wiring:<Thing />written into a panel but never reached, a hook whose result is ignored, and a handler that nothing invokes all read correctly in source. Every conversion under #2434 that asserted "the component is present" instead of "its numbers appear" survived deleting the component — one shipped that way and was caught only by mutation. - Geometry/WASM changes: mocked
@ifc-lite/wasmtests prove nothing about the boundary;pnpm test:wasm-contractruns the realbuildPrePassOnce/processGeometryBatchpath and pins the field surface plus unit-scale contract. Extend it when adding wasm API surface. It skips clean (exit 0) if the wasm runtime isn't built, so a local green there proves nothing unless you ranscripts/build-wasm.shfirst (CI restores the built artifact before the step).
- Discover the full SDK API with
ifc-lite schema(JSON).evalruns SDK expressions (ifc-lite eval model.ifc "bim.query().byType('IfcWall').count()"); always pass--jsonfor machine output.HeadlessBackend(packages/cli/src/headless-backend.ts) runs query/export/create/IDS/BCF without a renderer.
collab-server(packages/collab-server) persists CRDT state (Yjs update frames plus room/audit logs) to disk, so a persistence-format change needs legacy migration or you poison existing rooms (a data-loss class, #1501). Exercise the pair withpnpm test:collab(beyondturbo test); run the server withpnpm collab:server. Recipients rebuild the model from the CRDT via IFCX (snapshotToIfcxthenparseIfcxViewerModel), not a legacy STEP transport.
- One way to save a file:
apps/viewer/src/lib/export/download.ts. UsedownloadBlob/downloadFile/downloadDataUrl; never hand-roll an<a download>plusURL.createObjectURLdance, and never write another filename regex.downloadFilealready copies the wasmUint8Array<ArrayBufferLike>into aBlobPart, and emitsemitFileDownloaded(lib/tours/events) so task-gated tours can observe saves; a bespoke download path breaks tour progression, not just filename handling. - Run every user/model-derived filename through
sanitizeFilename(preserves case and dots, strips only OS-unsafe chars, see #1299). It is not a slug; don't lowercase or hyphenate names for filenames. Slugs (extension IDs) are a separate concern. - CSV and list exports neutralize spreadsheet formula injection on user-derived cells, including BOM-prefixed values (#1506). Route any new export column through the existing sanitizer; never hand-roll a CSV writer that skips it.
- Elements carrying
data-tourare interactive-tour anchors. If you move, rename, or delete one, updateapps/viewer/src/lib/tours/anchors.tsand the referencing steps underapps/viewer/src/lib/tours/tours/in the same PR. A broken anchor does not fail CI: it auto-skips at runtime and fires atour_step_brokenPostHog event, so rot surfaces on the dashboard, not in review. Tour telemetry (lib/tours/telemetry.ts) carries only registry ids, enums, and numbers, never file or model names (scrub-safe). - The tour demo kit reuses the committed
building-architecture.ifcsample as its base; its variants (building-architecture-rev-b.ifc,building-architecture.ids,demo-kit.json) are derived from that base bytools/demo-kit/derive-variants.mts. Revision B preserves the base's GlobalIds (diff matches on GlobalId, so a regenerated base would make compare read as 100% added/deleted) and carries the injected clash. Never hand-edit or regenerate one artifact in isolation; rerun the derivation script, which self-verifies the IDS/clash/diff invariants.
- Everything here is world-readable the moment it is pushed: code, docs, comments, commit messages, PR titles and bodies, changesets, issue text. Write as if a competitor and a client are both reading, because they are.
- Never name a client, customer, prospect, or partner organisation, and never describe a commercial relationship (engagement, contract, statement of work, pricing, fee, or the intent to convert one into another). Use a generic segment instead: "an AEC design platform", "a desktop authoring client", "an enterprise buyer".
- The same rule applies to anything a named party would consider non-public: their customer base or buyer profile, internal or unreleased API schemas, unreleased product plans, architecture they have not published, and any data they supplied.
- This binds commit messages and PR text as much as file contents. A commit that removes a client name but explains itself by naming the client has published it again.
- Roadmap, strategy and ecosystem docs are the usual offenders, because naming a real partner feels like it makes the argument concrete. It does not; the generic segment carries the same argument. If a claim only works with the real name attached, it belongs in a private repo.
- If you find such a reference, remove it and say so without repeating it. Removing it from the working tree stops further publication but does not erase git history, so flag it rather than assuming the fix is complete.
- MPL-2.0 header on every new file: see
./LICENSE_HEADER.md.
Respect assignments, and assign yourself before you start. This is not etiquette, it is the mechanism that stops two people building the same thing.
Before touching an issue:
gh issue view <n> --json assignees,title— if someone else is assigned, it is theirs. See "Helping on someone else's issue" below for the two ways that changes.- Look for an open PR on it.
gh pr list --search "<n>"is a TEXT search: it matches comment bodies, so it both misses linked PRs that never mention the number and returns unrelated ones that happen to contain it. Treat a hit as a reason to look, not as an answer, and confirm by opening the PR. The linked-PR list on the issue page is authoritative where the two disagree. - If both are clear,
gh issue edit <n> --add-assignee <you>before writing code, not when you open the PR. An assignment made at PR time claims nothing; the window it needed to cover has already closed.
Check again immediately before opening the PR. A claim can appear while you work, and the second check is the cheap one.
gh issue edit --add-assignee cannot tell two agents apart, because they all
push and assign as the same account. An issue assigned to that account
means somebody has claimed this. It does not mean you claimed it, and
you cannot tell which from the assignee field.
So an assignment to your own account is a claim by someone else until you can show otherwise. Leave a claim comment as well, naming the session, so the next agent can tell:
gh issue comment <n> --body "Claiming this. Session <id>, branch <name>."
And when you find the account already assigned with no claim comment, ask on the issue before starting rather than reading the field as your own.
Note this is about OUR sessions only. An outside contributor has their own account, so the field says what it looks like it says for them.
Doing one does not do the other, and the assignee field cannot cover both.
On #3012 the account was self-assigned at 14:12:52 and a PR for the same issue appeared 47 minutes later. The natural reading was another session ignoring the claim. It was not: the PR came from an outside contributor, who has no reason to know or care about an internal assignment, and for whom the repo's existing rules already apply (look for their PR before starting, external work takes precedence, never push to their branch).
The session that self-assigned never ran gh pr list --search 3012. No
assignee field, however precise about sessions, would have helped: no session
held it. Only the PR search would have.
So run both, every time:
gh issue view <n> --json assignees # is one of us on it
gh pr list --search "<n>" # is anyone at all on it
The checks above cover noticing before you start and noticing after you finish. The expensive case is neither: both of you are already half-built when the claim appears. Both have sunk work, both can reasonably feel they should be the one to finish, and the race is usually settled by whoever opens a PR first, which rewards speed over ownership.
The session named in the earliest claim comment decides. The other stops immediately rather than racing to open first, and hands over what it has as a comment or a patch on that session's PR.
"The assignee decides" is not usable here, because the assignee field holds one shared account and cannot name a session. The claim comment can, which is the other half of why it is required above. If no claim comment exists, ask on the issue rather than inferring from the field.
An outside contributor's PR still takes precedence over any internal claim, however early. They cannot see our claims and are not bound by them.
Stopping mid-build is cheap. Two finished implementations of the same thing is not, and neither is the conversation about which one lands.
Helping is welcome. Taking over is not. Two things make it help:
They accepted an offer. Comment saying what you would do and wait for a yes. Silence is not a yes, and neither is a thumbs-up on something else. An assignee who is mid-development and reads "we have already built this in parallel" is being told, not asked.
It has genuinely gone quiet. No commits on their branch and no word from them for about a week. Even then: comment first, say you will pick it up, and give them a couple of days to say otherwise. Then reassign explicitly rather than working in the shadows.
What is help regardless, no permission needed:
- Reviewing their PR, including finding real defects in it.
- Diagnosing a failing check and posting the cause and the fix.
- Answering a question they asked.
- Reporting a defect you found in shipped code, even if it came from their PR.
What is not help, however good the code:
- Building a parallel implementation and announcing it afterwards.
- Carrying an unraised branch that duplicates their work.
- Pushing to their branch.
- Opening a competing PR on their issue.
If you have already built something before noticing, say so plainly, hand it over, and let them decide whether to use it. That is recoverable. Landing it is not.
When you find you have duplicated someone:
- The person who was assigned keeps the work. Not whoever is further along, and not whoever noticed first.
- Do not close the duplicate silently. Enumerate what it holds that the surviving one does not, so nothing is lost when it goes, and post that list on both.
- Never push to a branch you do not own to "help". If a fix is a one-liner, say the one line in a comment.
This rule exists because it was broken, twice in two days, against the same external contributor. Issue #2951 was filed by them, assigned to them, and implemented in #2952 — and #2970 arrived fifteen hours later implementing the same thing. On #2670 they were assigned and mid-development when they were told a parallel implementation already existed.
The cost is not the wasted effort. It is that a contributor who did everything correctly had to be the one to raise it, twice.
Applies to every agent and every session, including short-lived subagents.
- Any delegated agent must obey this file: use the canonical load/geometry/export paths here, preserve IFC EXPRESS names, add no second load path, and prove changes with the narrowest local verification command. Treat delegated implementation output as a patch proposal until
git diffplus local verification pass. - Keep the orchestrator's context clean: delegate token-heavy filesystem work (broad search, log triage, fixture inspection, first-pass test repair) and get back a concise summary (files changed, commands run, result, risks), not raw logs or fixture dumps.
- The Fable 5 / Codex orchestration playbook (effort policy, subagent model routing,
codex execrecipes,/codex:*commands, handoff hygiene) lives in./ORCHESTRATION.md.
This root file is the shared contract; the closest AGENTS.md to an edited file also applies. Package-specific gotchas live in code-adjacent files: apps/viewer/AGENTS.md, rust/AGENTS.md, packages/geometry/AGENTS.md, packages/collab/AGENTS.md, packages/collab-server/AGENTS.md. Add more as a package accumulates its own footguns.