vuer_css’s .yarnrc sets --install.ignore-optional true. Jest 30’s resolver needs an optional native binding (@unrs/resolver-binding-<platform>), so ignore-optional skips it and every module lookup returns null — Jest then reports whatever module its config names first, which made this look like a ts-jest problem for a full debugging session. Affects macOS/arm64 local dev only; CI is green — the fix is a local node_modules patch, not a repo change.

Supersedes the earlier diagnosis (2026-07-30)

This note was originally titled “ts-jest@29.4.11 is a broken upstream publish (breaks yarn test:unit)“. That diagnosis was wrong. Swapping ts-jest versions changed nothing. Corrected 2026-07-31.

Second correction (2026-07-31, later the same day) — the ts-jest claim is fully RETRACTED

An intermediate revision of this note kept a section claiming “ts-jest@29.4.11 really is a broken publish, worth pinning to 29.4.10”. That is also wrong and is now retracted. The corrupt ts-jest directory I inspected came from a poisoned local Yarn cache entry, not from the registry — see The real secondary lesson — a Yarn v6 cache entry can be silently corrupt. After yarn cache clean ts-jest + a fresh install, node_modules/ts-jest/dist/index.js is present and ts-jest@29.4.11 works fine; the full unit suite passes against stock 29.4.11. Do NOT pin ts-jest to 29.4.10 — there is nothing to pin.

Scope narrowed (2026-07-31) — this is a macOS-local dev problem, not a CI problem

The root-cause mechanism below is confirmed exactly as written, but vuer_css CI is green on the same .yarnrc. Only macOS/arm64 local dev is affected. The .yarnrc change previously recommended here has been downgraded to a local workaround — see Fix — local dev environment only.

Symptom

Validation Error: Module ts-jest in the transform option was not found.
  <rootDir>/node_modules/ts-jest

…and, after “fixing” ts-jest, a second face of the same bug:

Module jest-circus/build/runner.js not found

These are ONE bug, not two

The earlier note recorded the jest-circus error as “a second, unrelated devel test-infra break”. It is not. Both are the identical null-resolution failure surfacing at a different point in Jest’s startup, once the first offending config entry stops being reached.

Root cause — ignore-optional starves Jest 30’s native resolver

Dependency chain:

jest 30
  └── jest-resolve@30.4.1
        └── unrs-resolver@1.12.2
              └── @unrs/resolver-binding-<platform>   ← OPTIONAL dependency
                    e.g. @unrs/resolver-binding-darwin-arm64   (Apple Silicon)
                         @unrs/resolver-binding-linux-x64-gnu  (CI / containers)

unrs-resolver ships its platform-specific native binding as an optionalDependency (the standard napi-rs distribution pattern — one optional package per platform, only the matching one installs). vuer_css’s .yarnrc contains:

--install.ignore-optional true

so Yarn never installs the binding. require('unrs-resolver') then throws “Cannot find native binding”, and jest-resolve’s Resolver.findNodeModule() degrades to returning null for every module — verified: ts-jest, jest-circus, lodash, and even jest-resolve itself all resolve to null.

Jest surfaces this as a complaint about whichever module its config mentions first (transformts-jest), which is why the error text points at an innocent package.

It is NOT --ignore-scripts (an intermediate suspicion, refuted)

Because the catch-up merge used yarn install --ignore-scripts, the obvious suspect was napi-rs’s napi-postinstall never running. Refuted by re-test: a full yarn install --frozen-lockfile with scripts enabled still leaves @unrs/resolver-binding-darwin-arm64 absent and require('unrs-resolver') still fails. The postinstall script can’t install a package that ignore-optional never downloaded — the failure is at fetch time, not build time.

Diagnostic one-liner — is it the resolver, or is it the package?

node -e "const R=require('jest-resolve').default; for (const m of ['lodash','jest-resolve']) console.log(m, R.findNodeModule(m,{basedir:process.cwd()}))"

If lodash and jest-resolve both print null, the native resolver binding is missing — it is not a per-package problem, and no amount of reinstalling the named package will help. If they print real paths, the named package genuinely is broken.

Fix — local dev environment only

For Agents

Do not change .yarnrc. The evidence does not justify a repo-wide change: CI is green on the same config (see Why .yarnrc was NOT changed). Install the missing binding into node_modules on the affected macOS machine and move on.

Leaves package.json and yarn.lock untouched — safe when you don’t want to widen a focused PR’s diff:

npm pack @unrs/resolver-binding-darwin-arm64@1.12.2
tar -xzf unrs-resolver-binding-darwin-arm64-1.12.2.tgz     # → ./package
mkdir -p node_modules/@unrs
rm -rf node_modules/@unrs/resolver-binding-darwin-arm64
mv package node_modules/@unrs/resolver-binding-darwin-arm64

Use the binding that matches the host (resolver-binding-linux-x64-gnu on the usual Linux boxes) and keep the version aligned with the resolved unrs-resolver in yarn.lock (1.12.2 here).

Result: yarn test:unit runs clean on the merged FKITDEV-8887 branch, against stock dependencies with only this binding added locally — 119 suites passed, 1051 tests passed, 47 skipped, 0 failures. package.json and yarn.lock are untouched by any of this.

Why .yarnrc was NOT changed

Dropping or narrowing --install.ignore-optional true was the original recommendation. It is not justified: the flag predates Jest 30 and is arguably stale, but the only observed breakage is on one macOS/arm64 workstation while CI — which honours the same .yarnrc — is green. Changing a repo-wide install flag to fix one machine is the wrong trade. Revisit only if a second environment (or CI) actually breaks.

Why is CI green? — ANSWERED (partially)

Answer: CI is green. .github/workflows/pull-request.yaml installs with yarn install --frozen-lockfile — the same .yarnrc, so ignore-optional applies there too — and all 7 checks pass on the FKITDEV-8887 head commit, including Unit Tests.

Still open: why the Linux self-hosted runners resolve the native binding when macOS/arm64 does not. Untested hypotheses (do not assert any of these as fact): a warm/cached node_modules on the self-hosted runner that predates the flag; @unrs/resolver-binding-linux-x64-gnu arriving through a different path than the darwin one; unrs-resolver having a usable fallback on glibc-linux; or the runner image shipping the binding globally. Worth a ls node_modules/@unrs on a runner if anyone gets shell access.

The real secondary lesson — a Yarn v6 cache entry can be silently corrupt

This is what the retracted “broken ts-jest publish” section was actually looking at.

Symptom: an installed package whose dist/ (or other built output) is simply missing, reproducing across repeated yarn install runs and across worktrees — which makes it look like a bad upstream publish.

Reality: the defect lived only in the local Yarn Classic cache at

~/Library/Caches/Yarn/v6/npm-ts-jest-29.4.11-<hash>-integrity/

Yarn happily reuses a cache entry indefinitely once it is written; nothing re-validates its contents on later installs, so one bad fetch poisons every subsequent install on that machine.

Fix:

yarn cache clean ts-jest          # scope to the suspect package
yarn install --frozen-lockfile

After that, node_modules/ts-jest/dist/index.js exists and ts-jest@29.4.11 works — the full unit suite passes against stock 29.4.11.

Tell-tale: a CI log file inside a node_modules package

The corrupt directory contained npm-view.err — the publisher’s own npm E404 output with paths under /home/runner/.npm. A stray build/CI log (*.err, *.log, npm-debug.log) sitting inside an installed package is evidence of a bad local cache artifact, not proof of a bad registry publish. Before blaming upstream, verify against the registry directly (npm pack <pkg>@<version> into a temp dir and inspect that tarball) — a cache-sourced directory proves nothing about what was published.

Reusable lesson

A Jest Module X ... was not found error names a victim, not a culprit. Before touching the named package, ask whether the resolver itself is dead:

  1. Run the findNodeModule one-liner above on two known-good packages.
  2. All null → resolver/native-binding problem (check .yarnrc, .npmrc, --ignore-optional, --no-optional, offline mirrors, platform mismatch).
  3. Real paths → then, and only then, inspect the named package on disk (ls node_modules/<pkg> for a missing dist/).
  4. If the package on disk is damaged, suspect the local cache before the registry: yarn cache clean <pkg> + reinstall. Only if it survives a clean-cache reinstall is it an upstream problem — confirm by inspecting a freshly npm packed tarball, not the installed directory.

More generally:

  • ignore-optional is not a safe blanket setting once any dependency uses napi-rs-style per-platform optional bindings. Same trap class applies to esbuild, swc, rollup, lightningcss, sharp.
  • A local-only reproduction is not a repo bug. Check CI before proposing a repo-wide config change; “it fails on my machine and CI is green” bounds the fix to your machine.