- Status: accepted
- Date: 2026-05-18
ADR-0024 fixed the dual-React useContext crash on consumer builds by
skipping nested node_modules/ when ensureBuildDir() copies the installed
package into .techradar/. That fix assumed everything else inside
.techradar/ was real files after the copy.
That assumption is wrong on pnpm-installed consumers running Node ≥26.
Consumer cs-technology-techradar@2.0.2 (pnpm workspace, engines.node >=22,
actual Node v26.0.0) consumes
@porscheofficial/porschedigital-technology-radar@2.2.0 and runs
techradar build:
Error: Cannot find module '@/lib/config'
Require stack: .../node_modules/.pnpm/@porscheofficial+porschedigital-technology-radar@2.2.0_postcss@8.4.31/node_modules/@porscheofficial/porschedigital-technology-radar/scripts/buildThemes.ts
at resolveTsPaths (.../tsx/dist/register-B4MtRGQg.cjs:10:718)
at wrapResolveFilename (node:internal/modules/cjs/loader:1048:27)
Note the requireStack: it reports the pnpm content-addressed store path,
not the .techradar/scripts/buildThemes.ts path that the shell cd'd into.
pnpm installs the package as a symlink tree from its content-addressed store:
<consumer>/node_modules/@porscheofficial/porschedigital-technology-radar/
→ <consumer>/node_modules/.pnpm/@porscheofficial+porschedigital-technology-radar@2.2.0_…/node_modules/@porscheofficial/porschedigital-technology-radar
ensureBuildDir() called:
cpSync(SOURCE_DIR, BUILDER_DIR, {
recursive: true,
filter: (src) => !src.includes(`${PACKAGE_NAME}/node_modules`),
});With Node's default verbatimSymlinks: false, cpSync does not dereference
symlinks: it rewrites them as absolute paths pointing back into the pnpm
store. So .techradar/scripts/buildThemes.ts is an absolute symlink into
.../node_modules/.pnpm/.../scripts/buildThemes.ts. Same for src/,
tsconfig.json, and every other file copied from the package tree.
When tsx scripts/buildThemes.ts then runs from inside .techradar/:
- Node resolves the entry point's
realpath()to the pnpm store path. - tsx's path-alias resolver (
resolveTsPathsintsx/dist/register-*.cjs) walks up from the entry's real path looking fortsconfig.json, finds the store'stsconfig.json, and resolves@/lib/configto a path inside the store. - On Node 26's CJS loader, that store-relative resolution returned from the
resolver hook is rejected with
Cannot find module '@/lib/config'.
The standalone case (running tsx /tmp/test-import.ts with the same import,
from the same .techradar/) succeeds, because the entry's realpath stays
inside .techradar/ and tsx finds .techradar/tsconfig.json — the issue
only manifests when the entry script itself lives in the symlinked tree.
The symptoms map cleanly onto npm-installed consumers being unaffected (npm
doesn't symlink into a store) and onto why CI on pdig/main never caught it
(monorepo dev builds operate on real files in the worktree).
Pass dereference: true to the cpSync(SOURCE_DIR, BUILDER_DIR, …) call
in ensureBuildDir() so that symlinks are followed and their target file
contents are copied as real files into .techradar/:
cpSync(SOURCE_DIR, BUILDER_DIR, {
recursive: true,
dereference: true,
filter: (src) => !src.includes(`${PACKAGE_NAME}/node_modules`),
});After dereferencing, every file inside .techradar/ is a real file whose
realpath() stays inside .techradar/. tsx's resolver therefore finds
.techradar/tsconfig.json and produces resolutions Node's CJS loader
accepts on every supported version.
Even with dereference: true, the consumer-build scripts are still
Node-tsx entry points whose path-alias resolution depends on tsx
intercepting Module._resolveFilename. That hook surface has historically
been a source of cross-version breakage (the Node-26-vs-tsx-4.21 interaction
above is the third such regression in this codebase). To stop relying on it
for entry-point resolution, the imports in scripts that run on the consumer
are rewritten as relative paths:
scripts/buildThemes.ts:@/lib/config→../src/lib/config,@/lib/theme/schema→../src/lib/theme/schemascripts/buildData.ts:@/lib/types→../src/lib/typesscripts/buildOgImages.ts:@/lib/types→../src/lib/typesscripts/positioner.ts:@/lib/types→../src/lib/typesscripts/theme/scanner.ts:@/lib/theme/schema→../../src/lib/theme/schemascripts/theme/assets.ts:@/lib/theme/schema→../../src/lib/theme/schema
Test files under scripts/__tests__/ and scripts/theme/__tests__/ keep
the @/* alias: they run via Vitest only inside the dev repo (the shadow
build excludes them via sanitizeShadowTsconfig()), and Vitest's resolver
is configured against the dev-repo tsconfig.json independently.
A behavioural regression test in
bin/__tests__/copyPackageToBuilder.test.ts builds a pnpm-shaped tmpdir
(scoped install symlinking per-entry into a .pnpm/<name>/ store), runs
copyPackageToBuilder(), and asserts every copied file is a real file
whose realpath() stays inside the builder dir and that nested
node_modules/ is filtered out. A second test in
bin/__tests__/shadow-build-symlinks.test.ts table-tests every
scripts/**/*.ts (excluding __tests__/) for absence of from "@/…"
imports.
The .dependency-cruiser.cjs rule no-deep-relative-imports is unaffected:
it only flags from: { path: "^src" }, not ^scripts, and only blocks
chains of three or more ../.
A. Add a tsconfig.json shim inside .techradar/ that re-asserts paths.
Already done — the shadow .techradar/tsconfig.json has the correct
paths mapping. It doesn't help, because tsx walks up from the real
entry path, not from cwd. The shim is invisible to the resolver.
B. Tell consumers to downgrade Node. Not viable. Node 26 is shipping
default on new macOS/Linux installs; the user explicitly refused to
downgrade. We must work on engines.node >=22 as declared.
C. Bump tsx to a version with different resolver semantics. Tried
tsx@4.x latest; same failure. tsx ≥5 hasn't shipped. Even if it did,
relying on tsx fixing its alias-resolution interaction with every future
Node major is fragile — the relative-import escape hatch is permanent.
D. Symlink the package into .techradar/ instead of cpSync. Would
move the problem, not solve it: the entry script's realpath() would
still land in the package's install location (store under pnpm).
E. Only convert imports to relative, skip the cpSync change. Would
fix the immediate Cannot find module crash. Rejected because the
underlying invariant — ".techradar/ is a self-contained shadow whose
real paths stay inside it" — is the same invariant ADR-0024 relied on for
the node_modules/ fix. Restoring that invariant explicitly is cheaper
than enumerating which downstream code paths assume it.
Positive
techradar buildworks on pnpm-installed consumers on Node ≥22 including Node 26, restoring the assumption that.techradar/is a self-contained build dir.- Entry-point scripts no longer depend on tsx's
Module._resolveFilenamehook to resolve the package's ownsrc/. The remaining@/*users are Next.js-compiled modules insidesrc/where Next/Webpack/Turbopack does the path-alias work, not tsx. - A regression test enforces both halves of the fix.
Negative
cpSync(…, { dereference: true })traverses the full source tree instead of writing symlinks. On the package's current size this adds a few hundred ms to the first build of each consumer; subsequent builds hit thehashshort-circuit unchanged.- Two import styles now coexist in
scripts/: relative for production entry scripts,@/*for test files. The split is documented in this ADR and enforced by the regression test.
Followups (not part of this ADR)
- None outstanding. The behavioural test on
copyPackageToBuilder()already assertsrealpath()of copied entries stays inside the builder dir, which subsumes the originally-planned runtime sensor.
The bug shipped in 2.2.0 despite an existing scaffolder-e2e job in
.github/workflows/ci.yml because the job had two blind spots that, combined,
made the failure mode invisible:
link:instead offile:install. The job didpnpm add "link:$GITHUB_WORKSPACE/packages/techradar". pnpm treatslink:as a direct directory link — no tarball, no content-addressed store, no symlinked layout.ensureBuildDir()therefore saw real files at the source andcpSyncproduced real files at the destination. The pnpm shape that breaksrealpath()-based resolution simply never existed in CI.- Single Node version. The job pinned
node-version: 22. The tsx-resolver-vs-CJS-loader interaction documented above only crashes on Node ≥26. Even if the layout had been correct, Node 22 would not have reproduced the failure.
Both gaps are closed in the same change:
- The job now packs the framework with
pnpm --filter @porscheofficial/porschedigital-technology-radar packand installs it asfile:<tarball>in the scaffolded sandbox. pnpm fetches, unpacks into.pnpm/<name>/, and produces the same symlinked surface a real consumer gets. - The job runs across
node-version: [22, 24, 26]withfail-fast: false, so any future Node-major-induced regression in shadow-build resolution fails CI on the affected version before publish.
Together with the behavioural test on copyPackageToBuilder(), the
@/-import sensor on scripts/, and the dereferencing fix itself, this
covers the three places a future regression could enter: the helper
implementation, the script imports, and the consumer-install shape.
After closing the two gaps above, the e2e job still missed PR-local changes
to seed content shipped by the framework (e.g. data/radar/*.md,
.markdownlint-cli2.jsonc). create-techradar resolves the framework
version via fetchLatestVersion() against the npm registry, writes
package.json with ^<latest>, installs that version, then runs
techradar init which scaffolds seed files from that registry copy
into the consumer CWD. The subsequent pnpm add file:<tarball> swaps
node_modules/... but leaves the already-written radar/ and
.markdownlint-cli2.jsonc untouched. The next pnpm run build:data
therefore lints the registry's stale seed against the registry's stale
config — both can be wrong while the local PR is correct, with CI green.
The job now wipes the scaffolded seed (rm -rf radar/ .markdownlint-cli2.jsonc)
and re-runs pnpm exec techradar init after the file: install, forcing
the scaffold step to source seed content and config from the local
tarball. A YAML comment in ci.yml explains the trap so a future
contributor doesn't simplify the rm+init step away.
- ADR-0021 (strip devDependencies from shadow
package.json). - ADR-0024 (skip nested
node_modules/when copying — the precedent for this class of "shadow workspace path identity" bugs).