Contributing
Choose work before coding
The engineering backlog is the repository's GitHub issues. Start from the Ready for contributors view, choose an unassigned issue, and comment before beginning a substantial change. Every promoted issue names its scope and the proof needed to complete it.
Use Discussions for design questions and the issue forms for reproducible bugs or concrete proposals. See the root contribution policy for the complete claim-to-merge workflow.
Verification gate
One command decides whether a change is acceptable:
scripts/gate.shIt runs formatting, a workspace build of all targets, the full test suite with all features, clippy with -D warnings, and rustdoc with -D warnings. It then runs the architecture gates and a feature matrix.
The gate has four sections, lint, test, features and bindings. scripts/gate.sh test runs one section; no argument runs them all. CI runs each section as its own job, in parallel, and the Standalone IFC gate check passes only when all four do.
Judge by exit code
The gate decides on exit codes. Never summarise a run by piping through grep or awk — a pipe hides the exit status and turns a failing suite into a clean-looking report.
Architecture gates
These are tests, so architectural rules fail CI rather than relying on review:
| Gate | Enforces |
|---|---|
ifc-model --test package_architecture | Dependency tiers; no sibling domain dependencies |
ifc-model --test progressive_context | One root AGENTS.md, a small README for every crate, and no dangling README/AGENTS pointer |
ifc-model --test module_reachability | No orphaned modules |
ifc-model --test no_monolithic_files | File size limits |
ifc-geometry --test declaration_manifest | Schema declaration inventory matches reality |
ifc-geometry --test no_backend_dependency | No CPU/GPU execution provider leaks in |
ifc-geometry --test kernel_free_build | The kernel-free column links no geometry crate |
openbim-ifc --test thin_build | Default features stay thin |
openbim-ifc --test docs_examples | Code in docs/ compiles and behaves as shown |
Documentation rules
Code in the docs is a test. Every Rust, JavaScript, Python and C example on the site is copied from a test that runs in the gate. The test marks the lines to publish:
// docs:snippet getting-started-read
let model = ifc::read(&bytes)?;
// docs:endand the page names the region the generator fills:
<!-- SNIPPET:getting-started-read -->
<!-- /SNIPPET -->A code fence in one of those languages outside such a region fails the gate, so an example cannot be pasted in by hand. Python tests use # instead of //.
Facts are generated, not typed. Crate counts, versions, install commands, the crate reference, the binding API tables, the coverage tables, the capability census and the changelog are all produced from the source:
cargo run -p xtask -- docs # regenerate every generated file and region
cargo run -p xtask -- docs --check # the gate: fail if any is out of dateGenerated regions sit between <!-- NAME:BEGIN --> and <!-- NAME:END -->; edit the source, never the region. A number in prose comes from docs/.vitepress/data/facts.json ({{ facts.crates.total }}), not from the author's memory. The gate also rejects git dependencies, pinned versions in TOML examples, spelled-out crate counts and absolute home paths anywhere on the site.
Claims need evidence. Do not describe a module as supporting something because it is named after it. A capability claim on the capability matrix must point at executable behaviour with a test. If it is reserved structure, it is Scaffold. State status in a table with an evidence column rather than in prose.
Spelling is consistent per page. British or American spelling is not enforced, but one page does not mix them.
No placeholder modules. Planned work is an issue, not a file that reserves a name; a module holding only comments fails the gate. See ADR 0019.
Where knowledge lives
Each fact has one home, chosen so it is found by whoever needs it and checked where it can be:
| Kind of fact | Home |
|---|---|
| A rule the code must keep | A test that fails when it breaks |
| A decision and its reasons | An ADR |
| Why a module is shaped the way it is | That module's //! docs |
| What a crate is for, and its crate-wide design notes | The crate's README.md (its crates.io page) |
| What every contributor must know before touching the repository | The root AGENTS.md, the only one |
| Open work | A GitHub issue |
A crate README is written for users first: purpose, cargo add, links to docs.rs and the reference page, then a short Design notes section holding only what no test, ADR or module doc already says. progressive_context keeps the READMEs small and checkbox-free, and rejects a second AGENTS.md.
Open work is a GitHub issue, never a checklist in the repository. A marker left in code names its issue, TODO(#123); cargo run -p xtask -- todo --check fails the gate on one that does not, and cargo run -p xtask -- todo lists them all so a closed issue's leftovers can be found. The proof of finished work goes in the pull request. A decision that should outlive it goes into a test, the module docs, the crate README, or an ADR.
Architecture and context gates are mutation-verified. Before trusting a new architecture test, break the rule it guards, watch the test fail, and restore. A filter that silently matches nothing passes forever, so every such test also asserts a minimum match count.
A performance claim needs a measurement: a committed benchmark definition, the baseline environment, and a measured comparison. A green correctness gate is not one. For the codec and the entity graph, run the baseline harness on the base and on the change and put its comparison in the pull request.
ADRs
Decisions that constrain future work get a record in docs/adr/, using _template.md. ADRs are immutable once accepted — a reversal is a new record that supersedes the old one, not an edit to it.
Record decisions already embodied in code. Open questions belong on the roadmap.
Standards material
ISO and CEN schema files, specification PDFs, and other licensed standards material are never committed to this repository or shipped in a published crate. Local reference copies live outside the repository tree.
Building the docs site
npm ci
npm run docs:dev # local preview
npm run docs:build # production buildThe site deploys to GitHub Pages from main via .github/workflows/pages.yml.
Developing against an unreleased Axiolid
When a capability is on Axiolid's main but not yet released, develop against a local checkout without changing a tracked file. Cargo.toml always names published versions: a path or git dependency builds only on the machine that has it, and the architecture gate (the_kernel_is_consumed_as_a_published_release) rejects it.
Redirect the published crates instead, in the gitignored .cargo/config.toml:
[patch.crates-io]
axiolid-core = { path = "../axiolid/kernel/crates/foundation/core" }
# one line per axiolid crate the workspace pinsCI resolves from crates.io and ignores the patch; returning to the registry is deleting .cargo/. Two pitfalls:
- Local tests passing does not prove the commit builds in CI, where the kernel is older. A change that needs an unreleased capability waits for the release.
- A patch applies only when its version satisfies the pin. After a kernel version bump every entry silently goes inert and cargo only warns. Check the patch is live:
cargo metadata --format-version 1 2>&1 >/dev/null | grep "was not used"prints nothing when every entry applied.
Never commit a Cargo.lock that records the local paths.
Releasing a crate
Every crate is versioned independently. Touching ifc-geometry means releasing ifc-geometry -- not every other crate in the workspace.
Start by asking what a release would cost:
python3 scripts/release-crate.py ifc-geometry --set 0.2.1This reports the crate's dependents and whether the bump is compatible. Cargo's ^ requirement is what decides:
| Bump | Meaning | Cost |
|---|---|---|
0.2.0 to 0.2.1 | compatible | publish that crate alone |
0.2.0 to 0.3.0 | breaking | every dependent must be edited and released too |
A dependent declaring ifc-geometry = "0.2.0" accepts 0.2.1 silently, so an additive change reaches consumers on their next cargo update with no republishing anywhere else. It rejects0.3.0, which is why a breaking bump cascades.
Write the bump once the cost is acceptable:
python3 scripts/release-crate.py ifc-geometry --set 0.2.1 --applyThat edits the manifest, opens a dated section in the crate's CHANGELOG.md, and -- for a breaking bump only -- lifts the requirement in each dependent's manifest. Fill in the changelog entry, run bash scripts/gate.sh, and commit.
Then release:
python3 scripts/release-crate.py ifc-geometry --publishThis refuses a dirty tree, then tags ifc-geometry-v0.2.1 and pushes the tag. The tag triggers .github/workflows/release.yml, which checks the tag is on main, waits for CI on the tagged commit and requires it to have passed, and publishes to every registry the crate targets. The gate runs once, in CI; the release does not run it again:
| Crate | Registries |
|---|---|
any crate without publish = false | crates.io |
openbim-ifc-wasm | npm only (@openbim/ifc) |
openbim-ifc-py | PyPI only (openbim-ifc): Linux, macOS and Windows wheels plus an sdist |
openbim-ifc-capi | GitHub release only: per-platform archives of its CMake package (header, static and shared library), plus SHA256SUMS |
openbim-ifc-dotnet | NuGet only (OpenBim.Ifc): one package carrying the shared library of every C library archive, built and tested on each runtime before it is pushed |
openbim-ifc-cli | crates.io (cargo install openbim-ifc-cli) and the GitHub release: the openbim-ifc binary for Linux (static musl, x86_64 and aarch64), macOS (x86_64, arm64) and Windows (x86_64), each smoke-tested and installed through install.sh before upload, plus SHA256SUMS, install.sh and the Homebrew formula openbim-ifc.rb, pushed to openbimrs/homebrew-tap when the HOMEBREW_TAP_TOKEN secret is set (Homebrew) |
The version in crates/openbim-ifc-wasm/npm/package.json, crates/openbim-ifc-py/pyproject.toml or crates/openbim-ifc-dotnet/dotnet/OpenBim.Ifc/OpenBim.Ifc.csproj must equal the crate's. --set --apply bumps it together with Cargo.toml, and refuses if the two were already out of step. The workflow refuses a tag that disagrees with any manifest. Every publish step skips a version that is already live, so re-running a partly failed release finishes it.
Each publish job runs in the environment named after its registry: crates.io, npmjs.com, pypi.org or nuget.org. Each asks for a reviewer's approval, and only release tags (*-v*) and main (for rehearsals) may deploy to them. Only admins may create, move or delete release tags. A deployment links to the version it published on that registry.
- npm, PyPI, NuGet and crates.io: trusted publishing (OIDC), no long-lived token. Each registry trusts exactly
release.ymland its own environment; renaming either breaks publishing until the registry settings are changed to match. - crates.io is configured per crate: in each crate's crates.io settings, add a trusted publisher with repository
openbimrs/ifc, workflowrelease.ymland environmentcrates.io. A new crate can only be configured after its first publish, so the workflow falls back to thecrates.ioenvironment'sCARGO_REGISTRY_TOKENsecret and warns whenever trusted publishing is refused. Keep the secret for new crates; a warning for an existing crate means its trusted publisher is missing. - NuGet: the policy belongs to the
openbimrsorganization on nuget.org and covers every package id, so it trusts a first push too.NuGet/loginexchanges the job's OIDC token for an API key valid one hour; it needs the nuget.org account the policy names, which thenuget.orgenvironment holds as itsNUGET_USERvariable. - npm: the first version of a new scoped package must be published by hand (npm cannot attach a trusted publisher to a package that does not exist yet); every later version comes from
release.yml.
If CI cannot publish, --publish --local runs cargo publish from a detached worktree at the tag instead, so the embedded VCS hash stays deterministic. A crate already live at its committed version is a no-op, so a rate-limited run can be repeated safely.
Changelogs
Each crate owns a CHANGELOG.md next to its Cargo.toml. The documentation page is assembled from all of them by cargo run -p xtask -- docs at build time. docs/project/changelog.md is gitignored: a committed copy made every pair of open pull requests conflict on it. Run the command once before npm run docs:dev in a fresh checkout. The root CHANGELOG.md is a frozen archive of the lockstep era through 0.2.0 and takes no new entries.
The gate fails if a publishable crate has no changelog, which is what stops a newly added crate from escaping this process.
Where help is useful
The roadmap explains product direction and shipped evidence. The public Project's Ready for contributors view is the authoritative shortlist of independently assignable work. Issues not shown there may be blocked, too broad, or awaiting an ownership decision.
Good contributions often include one of:
- a narrow model, codec, or authoring hardening task with mutation-sensitive tests;
- a redistributable IFC fixture and the test or guide that consumes it;
- an evidence-backed schema/version inventory that unblocks a domain slice;
- documentation that distinguishes shipped behavior from planned architecture.
Public API documentation
Every public item carries a doc comment. Every crate opts into [workspace.lints], where missing_docs = "deny" makes an undocumented public item a build failure, and scripts/check-missing-docs.py fails the gate if a library crate stops opting in. A new crate adds [lints] workspace = true to its Cargo.toml from the start.
The crate-level docs (//! in src/lib.rs) are also the overview on the crate's reference page, so write them for a reader choosing whether to depend on the crate.