MIT-licensed, and the source of truth for the CAD algorithms and the published @bitbybit-dev/* npm
packages. It stands alone: everything here builds and tests from a bare clone with nothing
above it. Start with README.md for the overview, CONTRIBUTING.md before opening a PR.
| Directory | What it is |
|---|---|
packages/dev/* |
the 16 published npm packages - see packages/dev/CLAUDE.md |
docs/ |
the Docusaurus site for learn.bitbybit.dev, including the generated API reference |
examples/ |
runnable examples per framework (angular, nextjs, nuxt, node, vite, react); examples/scripts/examples.mjs installs, builds and audits each one weekly from the registry, in examples.yml, and examples/scripts/local.mjs runs them against this repository's own packages instead |
languages/ |
the API help text per locale, keyed by each member's dotted path, and doc-paths.json, the map from documentation page names to those keys (API_DOCS_GUIDE.md) |
The packages form a dependency DAG, and the order has one source: each package's package.json
dependencies. scripts/gen-ts-references.mjs turns them into TypeScript project references -
every tsconfig.bitbybit.json is a composite project that references the siblings its manifest
declares, and tsconfig.build.json at the root references all thirteen - so tsc -b orders the
compiles itself and rebuilds only what changed. npm run build-packages is pnpm -r run build-p: pnpm orders the thirteen stagings by the same
manifests, each build-p compiles with tsc -b and stages dist/ for publishing.
npm run rebuild-all-packages empties every dist first; tsc -b tsconfig.build.json --verbose
prints the order it derives and what it considered up to date.
All three of a package's TypeScript configs are generated: the build config, the strict view, and the
tsconfig.json an editor and a lint run pick up - the build config's base and sibling paths without
its emit settings or exclusions, so the tests and mocks are in the project there. After changing a
dependency between packages, run npm run gen:references and commit the result; check:references,
the first step of npm test, fails when any of the three is out of date.
Three things are placed on purpose: the build info sits in each dist/, because tsc -b trusts it
over the outputs and it has to vanish with the dist it describes (copy-package's .npmignore keeps
it out of the tarball); every build config excludes dist and coverage, which TypeScript would
otherwise read as inputs; and every config states its outDir, which is how TypeScript keeps that
directory out of the project - a stale one reads the built dist back in as source, silently.
The sixteen packages under packages/dev/ are one pnpm workspace (pnpm-workspace.yaml): one
pnpm install at the root - npm run ci-packages is exactly that, frozen to the lockfile - installs
all of them, and a sibling dependency whose exact pin matches the sibling's version becomes a symlink
instead of a registry copy (linkWorkspacePackages). One pnpm-lock.yaml replaces the per-package
npm locks; npm run refresh-lockfile rewrites it without touching node_modules. The manifests keep
exact registry pins on purpose and never the workspace: protocol: dist/ is what npm publishes, and
copy-package derives its manifest through scripts/dist-manifest.mjs, which refuses a workspace:,
link: or file: specifier. A dependency's install script runs only when allowBuilds lists it, so
a new native dependency shows up as a decision, not a silent skip. Node comes from .tool-versions
and pnpm from packageManager, which pnpm switches to on its own.
Every dist-published manifest also carries an exports map derived from its tree by npm run gen:exports (the root, every directory index under lib/, every kernel module, then patterns), with
the @bitbybit-dev/source condition first in each entry: a consumer that declares the condition
resolves the TypeScript sources - the shared test configuration does, through resolve.conditions,
so a suite sees a sibling's edit without a rebuild - and one that does not resolves dist/. The map
never reaches npm: dist-manifest.mjs drops it with devDependencies and scripts, and a published
package resolves through main and types as every version has, because an exports map in a tarball
would refuse the extensionless deep imports the examples make. npm run check:exports holds every
manifest to that shape and every built dist/package.json to the derivation.
pnpm's layout is strict: a package resolves only what its own manifest declares, where npm's flat
hoisting let it reach anything a sibling had installed. Every import in lib/ must therefore be a
dependency of that package - the engine packages import @bitbybit-dev/base, the four workers,
jsonpath-plus and verb-nurbs-web directly, and declare them. Verify a build from a clone outside
your home directory: a stray ~/node_modules above the checkout satisfies an undeclared import on
your machine and nowhere else, which is how one reached CI.
npm run lint is ESLint 10 from one self-contained eslint.config.mjs: the recommended and
type-aware sets, the house style, and four local rules in eslint-rules/, none with a fixer, each
with a RuleTester suite. The code carries no comments but the public API's JSDoc (DTOs and
models included), tool directives, notices and a test's bare // Arrange / // Act / // Assert;
what a comment would say goes into a name, a constant, the package's ARCHITECTURE.md or
scripts/README.md. LINT.md holds the policy and every rule's reasons. Findings that predate a rule
sit in eslint-suppressions.json; a new one fails, as does a stale suppression, so the count only
falls. Never load eslint-plugin-no-comments: its fixer would delete the public API's JSDoc.
Every package builds and typechecks under the whole strict set, and the flags live in one place:
tsconfig.base.cad.json, which every package's tsconfig.json (the editor and test view) and
tsconfig.bitbybit.json (the build) extends - the SDK, the scaffolder and the MCP server included, which state only
their NodeNext settings on top. tsconfig.strict.json is the typecheck-only view: the build config
with nothing emitted, and with the build's references, because references are not inherited through
extends and a view without them once followed a sibling's declarations into another package's
source. npm run typecheck:strict runs it and must print nothing; npm run typecheck:tests does
the same over tsconfig.json, which keeps the tests and mocks - nothing else compiles those, the
build configs excluding them and a runner not typechecking what it executes.
npm run check:strict-baselines, which CI runs, holds the line: no package has a
.tsc-baseline.json any more, so any strict error anywhere fails it, and any tsconfig move is proved
flag-neutral with tsc --showConfig before and after. Test support under __mocks__ is excluded
from the build configs: the runner compiles it itself, so dist ships no mocks.
packages/dev/CLAUDE.md records the shape each kind of DTO property takes.
Before you commit, run all four - npm test alone is what leaves CI red after a green local run:
npm run lint # includes the AAA-only comment rule over every *.test.ts
npm run typecheck:tests # nothing else typechecks a test file
npm test # the check:* gates and all thirteen suites, with coverage
npm run check:coverage-baseline # reads what the npm test above it just measuredOnly typecheck:tests compiles a *.test.ts, and every column of coverage-baseline.json is a floor: code that dilutes one brings its own tests, never a re-recorded floor.
.github/workflows/verify.yml proves the repository builds and tests from a bare clone with nothing
above it, on every push to develop and every pull request into develop or master: the four above
plus check:references, check:exports, rebuild-all-packages, check:strict-baselines, the SDK's,
the scaffolder's and the MCP server's own builds and tests, check:openapi, api:check, check:tarballs, and last -
on a red run too - test:report, which puts every suite's results on the summary page. It needs no
secrets and must never gain any. nightly.yml runs the build and tests on every Node line the
packages should keep working on, and ifc.yml twice a week runs the ifc package's validate and
npm run ifc:corpus against IfcOpenShell. None of the three publishes.
publish.yml does, by hand-dispatch only, through npm trusted publishing: the job's OIDC token is
exchanged for a short-lived publish token per package, so no npm token is stored anywhere and every
version carries a provenance attestation naming this repository - which is why every manifest's
repository field is exactly git+https://github.com/bitbybit-dev/bitbybit.git with the package's
directory (scripts/dist-manifest.mjs refuses anything else). scripts/publish-packages.mjs
(npm run publish:packages) derives the tiers from the manifests, skips versions the registry has (a
failed run is re-run, never repaired by hand) and waits for a tier to resolve before its dependents
publish. The default dispatch publishes under next as a rehearsal; a second with latest releases.
The OCCT kernels the occt package ships are not tracked: packages/dev/occt/kernels.json names the
three content-hashed wasm files with their SHA-256 and the url each is published at, and
npm run kernels:fetch (run by both workflows after the install, and by the package's build-p)
downloads what is missing and verifies what is present. A kernel on disk that disagrees with the
manifest is an error, never overwritten.
Two of those checks carry committed state. api:check runs api-extractor in every package that can
carry a report - all but occt-worker, whose BitbybitOcctModule comes from emscripten glue it
cannot follow, and which check:worker-api and check:worker-parity pin harder instead - against its
built dist/index.d.ts, and fails when the public surface differs from the report in that package's
etc/: the dotted API is persisted in users' scripts, so a change lands only with a deliberate
npm run api:update and the report diff in the same commit. check:tarballs packs all sixteen,
installs the library ones into an empty project and probes each as a consumer would, then reads what
every tarball carries: a credential, an absolute build path, a source map naming excluded sources.
The API classes of the three worker packages (occt-worker/lib/api/occt/**, jscad-worker/lib/api/*.ts
except the barrel and the init class, manifold-worker/lib/api/** likewise) are generated from the
kernel by scripts/gen-worker-api.mjs: the class tree, the member order, the docs and the signatures
come from the kernel classes, kernel object types become the worker's pointer types, and every body
is one call that sends the method's dotted path to the worker thread. npm run gen:worker-api
rewrites them; npm run check:worker-api, the second step of npm test, fails when what the kernel
generates differs from what is committed. Never edit a generated file - change the kernel, or the
hand-written part: what cannot be generated (browser downloads, File/Blob preparation, a result the
main thread re-hydrates, the worker's own reserved commands) lives in lib/api-hand/<same path>.ts
as a class of the same name whose members carry a marker line saying where they land; the generator
merges them in. Where the worker
deliberately declares a type other than the mechanical mapping, scripts/worker-api.overrides.json
holds the type and the reason; kernel methods the worker does not expose are the kernelOnly entries
of scripts/worker-parity.allow.json, one list for the generator and the parity check.
The four inputs namespaces - occ-inputs.ts (some 300 DTOs), jscad-inputs.ts,
manifold-inputs.ts, verb-inputs.ts - are assembled the same way. Each has to stay one compilation
unit, because TypeScript does not merge a namespace across modules and tools that read the published
declarations read each as a whole, so scripts/gen-inputs.mjs writes it from the
fragments beside it, each a slice of the namespace body written as an ordinary module.
scripts/inputs.config.mjs names the four and their fragment order - the order of the emitted
declarations - and fails on a fragment nothing names. Edit a fragment and run npm run gen:inputs;
check:inputs fails on a stale assembled file, which being generated is not linted.
npm test at the root runs every package suite, after the check:* gates and test:scripts (the
generators' and checkers' own node --test suites, scripts/**/*.test.mjs). Each worker package
mirrors its kernel by dotted path, and scripts/worker-parity.mjs fails when a worker sends a path the kernel lacks, when a kernel method
has no mirror outside the allow-list, when signatures disagree, when the JSDoc on a mirrored method
or class reads differently on the two sides, or when the worker's path set differs from the committed
snapshot (those paths are persisted in users' scripts). A deliberate surface change is accepted
with --update; a doc change is made on the kernel and regenerated into the worker.
Every runner writes its results as JSON into test-results/ next to the code it tested
(test:coverage in every package), and coverage leaves coverage/coverage-summary.json beside it.
npm run test:report (scripts/test-report.mjs) collects them into one report - totals, a row per
package, what each moved against the recorded floor, failures, skipped tests, the slowest files -
printed to the terminal and, on Actions, written to the run's summary. That summary is the public
coverage report the docs link to, so write it for someone who did not run it. A package with a
test script that left no results is listed and fails the step, so a suite that silently stopped
running is noticed.
The Docusaurus site regenerates its API pages from an OpenAPI document, and llms.txt is generated
from docs/static/llms.template.txt by docs/scripts/generate-llms.js on every docs start and
build. Edit the template, never the output.
- Vitest for every package. Each has a
vitest.config.tsthat calls the shared factory inpackages/dev/vitest.shared.tsand states only what differs from it. UNIT_TESTING_GUIDE.mdis the testing standard;API_DOCS_GUIDE.mdthe one for the public API's JSDoc.- Kernel-heavy suites need a raised heap; the package scripts already set
NODE_OPTIONS=--max-old-space-size=8192. Keep that when adding one. They also run a process per file (pool: "forks"): the kernel holds global state and two suites sharing one corrupt each other. - Every package's tsconfigs extend
tsconfig.base.cad.jsonand keep only what differs: outDir, paths into sibling dists, exclusions, and the SDK's and the scaffolder's NodeNext settings. Change a flag for every package in the base, for one package in its leaf, and say so there: a base cannot be un-set by omission.