Skip to content

Known limitations

Fabr is under active development. It is fully self-hosting and builds, tests, and runs real JavaScript/TypeScript projects, but several things are deliberately incomplete or behave differently from the tools you may be used to. The most important ones to know about are below. (This page is about present behaviour; planned features that simply don’t exist yet are a separate matter.)

Host tools aren’t hermetically sealed yet

Section titled “Host tools aren’t hermetically sealed yet”

Fabr aims at deterministic builds, but there is one significant gap today: host programs are not modelled as identified dependencies. The node interpreter used to run JavaScript, the sh used by shell scripts, and any other tool fabr invokes on the host are found by a PATH search, and the resolved absolute path is what goes into the build cache key.

Two consequences follow:

  • Builds aren’t fully hermetic with respect to your toolchain. A different build of node (or another host tool) can change outputs, but fabr captures only its path, not its identity — so a genuine toolchain change may not invalidate the cache, and a build isn’t guaranteed reproducible across machines with different tools installed.
  • A PATH that changes every run defeats the cache. If you launch fabr through a wrapper that rewrites PATH per invocation — most notably yarn, which prepends a fresh temp shim directory each time — the interpreter resolves to a different path every run, the cache key changes, and work that should have been a cache hit re-runs. fabr test under yarn never hits the cache for this reason.

Workaround: invoke fabr by a stable path — install the CLI and run fabr directly (or node …/cli/build/index.js) rather than through a PATH-rewriting wrapper — and keep a consistent host toolchain across the machines that share a cache. This is wasted recompute, not incorrect output.

Jest is supported through a compatibility layer, with gaps; vitest is not

Section titled “Jest is supported through a compatibility layer, with gaps; vitest is not”

fabr test runs tests under fabr’s own runner, built on Node’s node:test. It provides the describe/it/before/after globals; you supply the assertion library yourself (list it in the target’s test_depschai, for example) and import it explicitly. Results are reported in CTRF form.

Jest-flavoured suites run through a compatibility layer. Select it per target (test_runner = @fabr-build/js-tools/jest-runner;) or project-wide (JS_TEST_RUNNER = @fabr-build/js-tools/jest-runner;), and declare @npm:@types/jest in test_deps for the globals’ types. It provides the jest object (mocks, spies, module mocking, fake timers), expect with jest’s matchers, .each, snapshots, and a jsdom environment — using jest’s own libraries, so their behaviour is jest’s rather than an imitation of it. It is fabr’s runner plus a compatibility layer, not jest hosted by fabr: none of jest’s own orchestrator runs. Setup files are a convention rather than a property: a source named setupTests at the root of the target’s source tree compiles with the tests and is loaded by every test process before any test file, with jest’s setupFilesAfterEnv semantics (both runner flavours honour it).

What is not there yet, each failing loudly rather than silently doing nothing:

  • {...styles} on a stylesheet import spreads to {} — the stub answers any property with its own name, but cannot enumerate class names it has never compiled. (Same limitation as jest’s usual identity-obj-proxy mapping.)
  • A declared mocks map (jest’s moduleNameMapper), and the per-file @jest-environment docblock pragma.
  • ESM-mode jest (useESM). Tests and the local module graph compile to CommonJS; external ESM-only packages load fine as leaves, and a top-level-await ESM graph can only be mocked (which is also true under jest).
  • window.location cannot be replaced — jsdom defines it as unforgeable, under jest too. Replace the global location instead.
  • Environment variables your npm test script used to set do not exist: fabr test runs with a clean environment.
  • The environment (node or jsdom) follows the target’s JS_TARGET, not a per-target property.
  • A jest-flavoured target needs @types/jest, whose dependency closure needs a handful of explicit version pins in your catalog (fabr will tell you exactly which).

Vitest is not supported. Its API is unavailable and there is no compatibility layer for it.

Recorded snapshots need fabr test -u, and their files must be declared inputs

Section titled “Recorded snapshots need fabr test -u, and their files must be declared inputs”

Snapshot files are ordinary sources: a target that uses them must list them, e.g. srcs = src:**/*.ts src:**/__snapshots__/*.snap;. A test run is hermetic — it works in a staged copy of its inputs and cannot write to your tree — so a missing or stale record fails rather than being quietly written. fabr test -u <target> re-runs with the runner asked to refresh its records, and fabr writes the changed ones back afterwards, reporting each. There is no --ci flag to remember: check mode is what a plain fabr test does, always.

Two limits today: an update run must be otherwise green (a test red for any other reason yields nothing, so no updates are applied), and the native runner has no snapshot assertions yet — fabr test -u against it says so rather than doing nothing.

npm resolution uses MVS and can differ from npm/yarn

Section titled “npm resolution uses MVS and can differ from npm/yarn”

Fabr resolves npm dependencies by minimal version selection (MVS): for each package it picks the highest of the minimum versions actually required across the build, with no lockfile. This is deterministic and reproducible — the same requirements always select the same versions — but it is a different algorithm from npm’s and yarn’s “newest version satisfying the range at install time”, so the results can differ:

  • The selected version may not match what npm install gives you. If a package only works correctly with a version newer than anything your build actually requires, raise the floor with an explicit requirement (e.g. @npm:some-pkg:1.4.2) — MVS will never silently pick a newer version for you.
  • One version per package for anything you compile or link against. Each package resolves to one version; requirements no single version can satisfy jointly (incompatible majors of a shared transitive, an exact pin against a higher floor) are a conflict, reported with the requirement chains on both sides and the override lines that would resolve it: a pin where a single satisfying version exists, a ? alternate (@npm:pkg:1.4.2?) to sanction nesting the second version exactly where it is needed, or a ! force (@npm:pkg:2.0.0!) to override an incorrect constraint. (Sealed tool closures — a js_script/script and its run delivery — nest conflicting versions npm-style without needing a sanction; the one-version rule holds for everything a target compiles or links into its output unless you sanction otherwise.)
  • Install-time behaviours don’t happen. Fabr fetches and assembles package contents; it does not run postinstall scripts or auto-install peer dependencies the way an npm client would. A package that depends on such behaviour may not work out of the box.
  • Unconstrained optional dependencies are skipped silently. An optional dependency pinned only as * (for example fsevents: "*") has no deterministic version under MVS and no lockfile to freeze one, so fabr drops it — the correct choice for reproducibility, but currently with no warning. If you need such an optional package, pin it with an explicit requirement.

Workaround: add explicit version requirements to raise floors, and use a catalog to pin one consistent set of versions across a project. A genuine need for two coexisting majors of a linked dependency is a current limitation.

Concurrent fabr processes duplicate work rather than sharing it

Section titled “Concurrent fabr processes duplicate work rather than sharing it”

Fabr deduplicates in-flight work and effectively write-locks cache entries within a single process, so all of one build’s internal parallelism is safe. There is, however, no cross-process lock on the build cache: two fabr processes running at the same time that both miss the same entry will each build it.

That costs time, not correctness. Everything transient lives in a per-process work tree (work/<host>-<pid>/) and every commit into the store is atomic — a rename into the content pool, temp-plus-rename for a manifest — so the two runs cannot interleave into a corrupt entry, and for a deterministic build the loser’s result is byte-identical to the winner’s.

Workaround: none is needed for correctness. To avoid the duplicated work, don’t run two fabr builds against the same cache concurrently, or give each its own FABR_CACHE_DIR. Note also that deleting the cache from under a running fabr run/serve removes the live staged install with it.

Watch mode can leave a program running if fabr is force-killed

Section titled “Watch mode can leave a program running if fabr is force-killed”

fabr run -w (including on a serve target — there is no separate serve verb) supervises the program it launches and tears down its whole process group — including any workers it forked — on every restart and on every orderly exit (Ctrl-C, SIGTERM, SIGHUP, or an uncaught error). But if fabr itself is force-killed — SIGKILL, an out-of-memory kill, or a crash — nothing can run to clean up, and the launched program is orphaned, left running with no supervisor. Only a hard kill of fabr does this; every ordinary way of stopping it shuts the program down cleanly.

Workaround: stop a watching fabr with Ctrl-C or SIGTERM rather than kill -9. If a program is orphaned, stop it by hand (for a server, finding it by the port it holds is usually easiest).

A TypeScript source in a package’s deps is emitted into the package

Section titled “A TypeScript source in a package’s deps is emitted into the package”

A target’s deps can carry plain source files that it compiles against but doesn’t distribute. A .d.ts type-only file emits nothing and is correctly never shipped, but a .ts source file in deps is compiled and its resulting .js currently ends up in the built package output.

Workaround: to share compiled TypeScript between targets, declare it as its own package and depend on that, rather than adding the raw .ts file to deps.