@smoothbricks/nx-plugin
v0.4.12
Published
Local Nx plugin for workspace-standard package setup and missing inferred targets.
Readme
Nx Plugin
Local Nx plugin for workspace-standard package setup and missing inferred targets.
Target Ownership
Official @nx/js/typescript inference is disabled because it cannot run the workspace's source transformers. A package
with tsconfig.lib.json receives transformer-aware tsc-js and native typecheck targets from this plugin.
@smoothbricks/nx-plugin also owns inferred targets Nx does not provide here:
typecheck-testsandtypecheck-tests:watchfromtsconfig.test.jsontest:watchfrom explicittestcommands for Bun and Vitest packages- Cargo workspace targets from a neighboring workspace-root
Cargo.toml - aggregate
buildandlinttargets - an uncached
deployfor a project with a wrangler manifest, runningsmoo wrangler deploy-stage --stage {args.stage}
The deploy is one target that takes whichever stage it is given, never a configuration per stage: pull-request stages
are prN for unbounded N, so nx run <project>:deploy --stage=pr42 has to work for a stage no enumeration could list.
It is never cached, because a cache hit would mean "we once uploaded this hash", which a rollback falsifies silently.
Declaring deploy locally overrides only the properties it names, so a project adds cross-project ordering with
"dependsOn": ["...", "backend:deploy"] and keeps the inferred command; a project that declares deploy-build gets
that edge instead of build, and the plugin caches that half without inventing its command.
Lint commands are inferred per project. A workspace Biome configuration enables the project-wide Biome check; an ESLint
flat configuration enables ESLint only for existing JavaScript/TypeScript files under that project's src. Rust-only
source trees therefore keep their Cargo validation and manifest checks without invoking ESLint on nonexistent JavaScript
inputs. Adding JavaScript or TypeScript sources adds their lint coverage automatically.
Keep targetDefaults.lint limited to shared cache policy. Smoo's workspace policy removes static lint executors,
commands, dependencies, inputs and outputs because they override source-aware inference for every project. Project-local
nx.targets.lint declarations retain normal Nx override precedence.
Cargo Workspace Layouts
The plugin discovers Cargo workspaces beside Nx project manifests at any depth:
- Workspace owner: the project beside a workspace
Cargo.tomlowns onecargo-fetch,cargo-test-compile,cargo-test-archive,cargo-lint, cross-check, mutation, bench, and sweep target. Itscargo-lintandcargo-testaggregates reach all workspace crates, including a root[package]. - Crate owner: the deepest Nx project directory containing a crate owns
cargo-test-<crate>targets. Itscargo-lintdelegates to the workspace owner's one verdict, andlintnever depends on the workspace test suite. Projects can own multiple crates. A nested independent workspace has its own prerequisites and does not inherit those of an outer workspace. - Commands: validation runs from the governing Cargo workspace directory. One
cargo fmt --all --checkand onecargo --frozen clippy --workspace --all-targets --target-dir target/cargo-lint -- -D warningscover every member. Clippy of a crate is a check build of its whole closure, so a target directory per crate rebuilt every shared dependency once per crate — 49 closures and 11 GiB on one repository — and no per-crate cache hit repaid it. Cargo's own lock serializes the invocations that share the directory. - Overrides: normal Nx merging applies. Explicit
nx.targetsfields replace inferred fields, while omitted fields retain their inferred base. Use"dependsOn": ["...", "extra"]to preserve inferred prerequisites when adding an edge; replacing the array makes its author responsible for fetching before frozen Cargo commands.
Member and exclude patterns may use * and ? in any path segment (for example, packages/*/crates/*). Unsupported
patterns and member patterns that match no directory fail graph construction instead of silently omitting crates.
Cargo cache boundaries
cargo-fetch is uncached because the registry lives outside Nx outputs. cargo-test-compile is also uncached: it warms
Cargo's shared mutable build directory before the bounded runner, even if that directory was deleted after an earlier
successful run. Cargo's own incremental cache avoids recompiling unchanged code. Nx never restores or collects shared
Cargo build directories; lint and test results, and dedicated Wasm/N-API outputs, remain cacheable.
Validation inputs include the crate's files, local dependency closure, governing manifests and configuration, toolchain versions, Cargo/Rust/native-build environment, target/profile overrides, and global Cargo configuration. Workspace test runners additionally hash all workspace member manifests because those affect unified features, and the nextest executable/configuration. Production test configurations use release compilation and release runners together. Keep explicit inputs for arbitrary build-script reads or custom environment variables that Cargo metadata cannot name.
Cargo keeps the caller's CARGO_HOME. Moving configuration into an isolated home can change relative paths or lose
source replacement, credentials, and toolchain settings; forwarding it with --config changes precedence. Registry
access may consequently serialize on Cargo's package-cache lock. Clippy's dedicated target directory keeps its check
artifacts out of the test build directory without splitting one directory per crate.
External Rust sources
For a repository-root Cargo workspace, declare the shared input once:
{
"namedInputs": {
"externalRustCrates": [{ "runtime": "smoo-nx-cargo-hash" }]
}
}For a package-root Cargo workspace, pass its manifest relative to the Nx root:
smoo-nx-cargo-hash packages/example/Cargo.toml. A shared input covering several Cargo workspaces includes one runtime
entry per manifest.
The command queries cargo metadata --locked --offline and hashes the resolved external path packages, including
transitive dependencies, Rust sources, target source files, governing Cargo manifests, and Cargo configuration. It also
covers path packages under node_modules, which Nx's normal file map ignores. Adding or moving a dependency does not
require maintaining a second list of source roots. The command refuses missing dependencies or an unavailable locked
dependency cache instead of emitting a partial digest.
Git and registry dependencies are identified by Cargo.lock; uncommitted changes in a Git repository are not changes to
a pinned Git dependency. Local path dependencies stay live and their source edits change the digest. Build-script data
and environment inputs outside the Rust/Cargo inputs above still require explicit Nx inputs.
Manifest versions and validation inputs
A release rewrites version in every package manifest it publishes and in the lockfile entries mirroring them. Those
files are inputs of lint, typecheck and typecheck-tests, so a publish run misses the cache for the whole gate set
over a change that alters no code. Those three targets therefore leave the raw manifests out of their filesets and hash
them by field instead: a json input with excludeFields for package.json#version, and one dropping the lockfile's
workspaces section, which is only the mirror of each member's own manifest. Nx hashes both in its native hasher, so
this costs no process. Everything else still counts: a dependency range, an export map, and the lockfile's resolution
table.
Crate manifests are the one kind Nx cannot hash by field, because TOML has no such input. The plugin hashes them itself,
in the process that builds the graph and is already reading them: a single forward scan over the bytes feeds the hasher
every run it keeps, dropping [package].version and the [workspace.package].version members inherit and nothing else
— a version naming a different crate under a dependency table stays in the digest, and anything a line-oriented scan
cannot read (a multi-line string, an unterminated value) is hashed verbatim rather than guessed at. The digest then
travels as a literal input path that matches no file: Nx hashes a project's namedInputs definitions into that
project's configuration hash, which is part of every task in the project and of every task depending on it, so the value
reaches the hash with no process at all. Earlier revisions spawned a command per crate project per graph computation;
that spawn is gone.
Targets that produce a shipped artifact keep hashing the raw manifests. A crate embeds its version at compile time
through env!("CARGO_PKG_VERSION"), so a version-insensitive build, pack, tsc-js or cargo hash would let a
post-bump run hit a pre-bump artifact and publish a binary reporting the previous version.
A dependency's manifests are hashed the same way once the workspace declares the named input, which gives projects this plugin does not infer a definition to resolve:
{
"namedInputs": {
"versionlessProduction": ["production"]
}
}Without it the dependency half stays ^production. On an Nx older than 23.2, which rejects a json input rather than
ignoring it, the targets keep today's inputs entirely; the same is true of crate manifests when the command is not
installed. Every fallback costs cache hits and never trades away invalidation, because Nx runs a runtime input without
reporting a failing one — a fileset that excluded a manifest with no digest replacing it would serve stale results
silently.
Nx Target Naming
Target names are {tool}-{output} names. Use names like tsc-js, tsdown-js, and cargo-wasm; build and lint are
aggregates.
Concrete targets come from concrete files:
tsc-jsemits transformed JavaScript through a temporary JS-onlyttscconfig, restores execute bits on outputs declared bypackage.json#bin, then emits declarations and declaration maps from the original project with nativetsc. Direct project references and every cached output lane are preserved.typecheckis inferred fromtsconfig.lib.jsonand runs nativetsc -p tsconfig.lib.json --noEmit.typecheck-testsis inferred fromtsconfig.test.jsonand runstsc -p tsconfig.test.json --noEmit. It first rebuilds the current package'stsc-jsoutput (or its non-TypeScriptbuild) so self-imports resolve afterclean.typecheck-tests:watchis inferred fromtsconfig.test.jsonand runs the same typecheck in watch mode.test:watchis inferred when the package already defines an explicit Bun or Vitesttestcommand. The plugin derives the corresponding watch command and makes it depend ontypecheck-tests.- A workspace-root
Cargo.tomlprovidescargo-test,test,cargo-lint,mutation, andbench.cargo-test-compilewarms one workspacecargo test --no-run.cargo-test-archiveruns onecargo --frozen nextest archive --workspaceintotarget/nextest/archive.tar.zstand is the only cached cargo BUILD: it produces a file rather than a mutable build tree. Each member crate gets a cachedcargo-test-<package>run (30s per-test timeout likebun test --timeout=30000) whose inputs include that crate and its path dependencies, and whose command iscargo --frozen nextest run --archive-file <archive> --workspace-remap . -E 'package(<crate>)'. Runners therefore execute rather than build: they extract binaries into their own temporary directory, write nothing to cargo's flockedtarget/, and fan out instead of chaining.--workspace-remapis required — an archive records the producing tree's absolute paths, and without it a restored archive hands tests another checkout'sCARGO_MANIFEST_DIR. With it the archive is relocatable, so one cache entry serves every checkout of the same commit; nextest re-pointsCARGO_BIN_EXE_<name>/NEXTEST_BIN_EXE_<name>at the extracted binaries at runtime, but a test that reads them through the compile-timeenv!macro keeps the producing tree's path. Per-crate runners accept an empty nextest selection because a valid workspace member may have no tests and a hash partition may legitimately be empty.napi-debugstays behindcargo-test-compile, and a crate in the project that builds the debug cdylib runs after it. A crate declaring[package.metadata.smoothbricks.wasm-bindgen]also receives the cacheablecargo-wasmoutput target in its owning project. - The plugin's own
nextest.tomlis passed as--tool-config-file "smoo:$PWD/<path>", so it is a layer BENEATH the repository's<cargo-workspace>/.config/nextest.tomlrather than a replacement for it: a repository can raise a timeout, add a test group, or declarearchive.includefor a cdylib or fixture the archived test binaries need, and its settings win. That file is an input of both the archive and every runner, so changing it invalidates the verdicts it governs.$PWDkeeps the command text identical across checkouts while satisfying nextest's requirement that a tool config path be absolute;--user-config-file nonestill holds, because a developer's~/.config/nextestmust not decide a cached verdict. - A crate whose suite outgrows one bounded window declares
[package.metadata.smoothbricks.test] shards = N, and getscargo-test-<package>-shard1..N, each running--partition hash:i/Nwith the full bound. nextest assigns a test to a shard by hashing its name, so the shards stay an exact partition of the crate as tests and test binaries are added, and a staleNcan only make a target slow — never drop a test. Omitting the key means one target, as before. - Tests that a
nextest.tomloverride singles out are lifted out of the hash intocargo-test-<package>-exceptions, and the shards run the complement. An override marks a class that does not behave like the rest of the suite, and each kind breaks a shard differently: atest-grouponly holds within one nextest run, so leaving its members to the hash would scatter them across runs and dissolve the mutex; a raisedslow-timeoutmarks a test whose cost is not the suite's, such as a compile-fail test that rustc's a fixture for 25.6s on a cold target directory against 1.8s warm — and every CI runner is cold. One target holds both classes, not one each: they occupy different threads, so its wall is the max of the classes rather than their sum. The pin is derived by reading the overrides back out ofnextest.toml, so declaring one there is the whole change. Only a sharded crate gets this target — an unsharded crate already runs its whole suite in a single process — and it passes on an empty set, which is the correct answer for a platform where the singled-out tests arecfg-ed out. - Canonical
napipackage metadata provides a hostcargo-napitarget and named release targets for each configured triple. Linux--use-napi-crosstargets compile C/C++ dependencies with Clang; the NAPI CLI supplies its downloaded GNU sysroot and toolchain flags. This avoids the bundled GCC's unsupported diagnostics-color flag without disabling the workspace's sccache wrapper. - Each
--use-napi-crosstriple also gets anapi-toolchain-<arch>-linuxprerequisite that extracts the pinned@napi-rs/cross-toolchain-<host>-target-<arch>archive into~/.napi-rs, where the NAPI CLI probes for it. Every cross build of that triple — the inferrednapi-<arch>-linuxand any package-localcli-<arch>-linux— depends on it, so the CLI's own downloader never runs. That downloadernpm packs into its own package directory, which under Bun's isolated global store is a shared (in CI host-wide) cache: two concurrent cross builds of one triple would otherwise pack the same file into the same directory and one would die on the other's cleanup. The prerequisite produces no artifact, so it stays out of the aggregatebuildand out of collected platform outputs. buildis inferred only when the project has at least one concrete build target to run, such as inferredtsc-js, a package-local target liketsdown-js, orcargo-wasmfrom this plugin. It depends on output-family wildcard targets:*-js,*-web,*-html,*-css,*-ios,*-android,*-native,*-napi,*-bun, and*-wasm.
Dependency output lanes
Compiler targets depend on ^*-js, not ^build. The caret asks Nx for every matching JavaScript output target on
project dependencies; it does not directly select their Wasm, N-API, native, web, or aggregate build targets. This
keeps a declaration or JavaScript compile from paying for unrelated platform artifacts merely because a dependency
package publishes them.
The selected JavaScript target retains its own dependsOn edges. A platform artifact that is genuinely required to
produce that JavaScript therefore still participates transitively:
consumer:tsc-js
└─ dependency:bundle-js
└─ dependency:cargo-wasmA wildcard without a caret selects the current project instead. For example, a workspace may add *-wasm to its
tsc-js target default when the current package's generated Wasm module must exist during compilation. The plugin makes
this local relationship explicit for inferred tsc-js targets that also have inferred cargo-wasm. Package-managed
JavaScript targets with another platform prerequisite must likewise declare that local edge themselves.
N-API binaries are not prerequisites of TypeScript declaration emission, so compiler targets do not pull them in by
default. Aggregate build remains the package-completeness boundary and includes every published local output family.
Do not use colon-style Nx target names such as build:wasm or lint:fix. Nx CLI syntax already uses colons for
project:target:configuration, so colon target names are hard to read, easy to confuse with configurations, and awkward
to expose through package scripts. Package scripts may still use names like build:wasm; they should delegate to a real
target such as nx run pkg:cargo-wasm.
There is no Nx lint:fix target; repository formatting is handled by the root lint:fix script.
typecheck-tests and typecheck-tests:watch are inferred only when tsconfig.test.json exists. Test typechecking must
not emit dist-test. test:watch is continuous and depends on typecheck-tests before entering Bun or Vitest watch
mode. Smoo validation creates/requires this config for test runners that do not typecheck test files by default.
tsconfig.test.json is not a TypeScript build-mode project. It should reference library tsconfigs it needs to typecheck
against, but the package root tsconfig.json should not reference ./tsconfig.test.json. Nx runs test typechecking
through the inferred typecheck-tests target, not through tsc --build.
Ensure a Target Before Executing Its Binary
ensureBuilt checks a target and its task dependencies against Nx's local cache and the daemon's recorded output
hashes. A full hit returns without running tasks or printing output. A miss runs the task graph through Nx's in-process
runner with streaming output; only a workspace with the daemon disabled falls back to its checkout-local nx CLI.
import { ensureBuilt } from '@smoothbricks/nx-plugin/ensure-built';
const result = await ensureBuilt({ target: 'my-cli:build', cwd: workspaceRoot });
if (result.disposition === 'failed') {
if (result.signal !== null) {
process.kill(process.pid, result.signal);
}
process.exit(result.exitCode);
}The packaged smoo-nx-exec wrapper performs the build check and then replaces itself with the binary:
smoo-nx-exec my-cli:build -- ./packages/my-cli/dist/my-cli argumentIt is deliberately not nx run <target> && exec:
- A hit costs one daemon round-trip and a local cache read, not an Nx CLI start.
- A hit is silent.
nx runreplays the cached log and prints its[local cache]banner, which a CLI wrapper must not print. - It execs the binary, preserving its argv, exit status, and signals.
nx runhas no notion of handing the process over to another binary.
Pass --workspace-root <dir> before -- when invoking the wrapper outside the workspace. The binary path resolves
against the caller's current directory.
Bun Test Tracing Generator
Configure a package for the Bun test tracing + no-emit test typechecking pattern used in this repo.
nx generate ./packages/nx-plugin:bun-test-tracing \
--project @smoothbricks/my-package \
--opContextModule @smoothbricks/lmao \
--opContextExport lmaoOpContext \
--tracerModule @smoothbricks/lmao/testing/bunWhat it wires:
bunfig.tomlpreloads for the LMAO Bun test tracing setupsrc/test-suite-tracer.tstsconfig.test.jsonwithnoEmitfor inferredtypecheck-tests- direct test config references to library tsconfigs; package root
tsconfig.jsonis left out of the test config graph - package
package.jsontest/lint/devDependency wiring needed for the standard pattern
Bounded Test Targets
@smoothbricks/nx-plugin:bounded-exec runs a shell command with a timeout and force-kill grace period. Test targets use
this executor so hung test processes fail predictably instead of blocking Nx indefinitely.
The shared policy API is exported from @smoothbricks/nx-plugin/bounded-test-policy for generators or other workspace
tools that need to normalize package JSON consistently.
nx generate ./packages/nx-plugin:bounded-test-targets --project @smoothbricks/my-packageThe generator rewrites package.json so nx.targets.test uses:
- executor
@smoothbricks/nx-plugin:bounded-exec - command preserved from an existing
nx:run-commandstest target or directscripts.test cwd: "{projectRoot}"timeoutMs: 600000killAfterMs: 10000- package script alias
nx run <project>:test --outputStyle=stream
