Skip to content

build(nx): run hot targets directly instead of via npm run - #324

Open
laazyj wants to merge 2 commits into
mainfrom
claude/composurecdk-nx-run-commands
Open

build(nx): run hot targets directly instead of via npm run#324
laazyj wants to merge 2 commits into
mainfrom
claude/composurecdk-nx-run-commands

Conversation

@laazyj

@laazyj laazyj commented Jul 19, 2026

Copy link
Copy Markdown
Owner

What & why

Closes #323.

CI's typecheck/lint/build/test job intermittently fails on a single matrix leg with nx reporting a task failed (e.g. @composurecdk/eslint-plugin:typecheck) while the same commit passes on every other Node leg. It is not a compiler error — the underlying tool never runs. nx executes package.json script targets through the package manager (its run-script executor spawns npm run <script>), and under parallelism an npm startup intermittently crashes inside npm's own config loader:

Exit prior to config file resolving
cause
call config.load() before reading values     ← @npmcli/config, not nx, not tsc

(Upstream: npm/cli#8425, closed not planned; not fixed by upgrading nx or npm.) See #323 for the full trace.

This maps each package's hot targets — build, typecheck, test, check:exports, lint — to nx:run-commands under the package.json nx.targets block, so nx runs the tool directly (tsc --noEmit, eslint ., tshy, …) with cwd pinned to the project root, eliminating the npm subprocess that crashes.

Details

  • The package.json scripts are unchanged — they stay the source of truth for each command and keep npm run <script> working for humans; nx.targets mirrors them.
  • nx.json targetDefaults continue to supply each target's dependsOn / cache / inputs / outputs — the override only swaps the executor from the inferred nx:run-script to nx:run-commands. Verified that targetDefaults cannot override an inferred target's executor, which is why the override lives per package.
  • Commands are copied verbatim from each package's existing script, so the per-package variants are preserved (tshy vs tsc -p tsconfig.build.json for build; vitest run vs vitest run --passWithNoTests; packages without a build/check:exports script get no such target).
  • No @nx/eslint inference plugin is introduced — the lint override still runs eslint . at task time and keeps its dependsOn on @composurecdk/eslint-plugin:build, so the fresh-checkout ordering AGENTS.md describes is unchanged.
  • AGENTS.md documents the pattern so new packages include the block.

Validation — clean, CI-like state

Wiped node_modules, npm ci, nx reset, removed all dist/coverage, then ran the full gate. All green, and dual ESM/CJS dist artifacts are produced correctly:

target result
build (run-many) ✅ 22 projects, > tshy / tsc run directly (no npm run)
typecheck ✅ 23 projects, EXIT 0
lint ✅ 24 projects, EXIT 0
check:exports ✅ 20 projects, EXIT 0
test ✅ 23 projects, EXIT 0

Checklist


Generated by Claude Code

nx runs package.json script targets through the package manager: its
run-script executor spawns `npm run <script>`. With many tasks running in
parallel, an npm startup intermittently crashes inside npm's own config
loader ("Exit prior to config file resolving" / "call config.load() before
reading values", npm/cli#8425), failing the task before the underlying tool
runs — a flake unrelated to the code under test.

Map each package's hot targets (build, typecheck, test, check:exports, lint)
to nx:run-commands under the package.json `nx.targets` block, running the tool
directly (tsc/eslint/tshy/…) with cwd pinned to the project root. The scripts
stay as the source of truth and for `npm run` by humans; nx.json targetDefaults
still supply dependsOn/cache/inputs/outputs. targetDefaults cannot override an
inferred target's executor, so the override lives per package.

Validated from a clean CI-like state (fresh npm ci + nx reset): build,
typecheck, test, check:exports, and lint all pass, and dual ESM/CJS dist
artifacts are produced correctly.

Closes #323

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEDVnuJAM7zgUUgLNnxn9j
@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Coverage

Overall line coverage: 99.34% across 23 package(s).

Package Statements Branches Functions Lines
acm 🟢 97.36% 🟢 94.73% 🟢 100.00% 🟢 97.22%
apigateway 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
budgets 🟢 99.20% 🟢 95.55% 🟢 100.00% 🟢 100.00%
cloudformation 🟢 97.69% 🟢 95.40% 🟢 100.00% 🟢 99.16%
cloudfront 🟢 99.40% 🟢 95.14% 🟢 100.00% 🟢 100.00%
cloudwatch 🟢 95.56% 🟡 89.55% 🟢 100.00% 🟢 98.23%
core 🟢 100.00% 🟢 97.36% 🟢 100.00% 🟢 100.00%
custom-resources 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
dynamodb 🟢 100.00% 🟢 92.00% 🟢 100.00% 🟢 100.00%
ec2 🟢 98.81% 🟢 97.72% 🟢 100.00% 🟢 99.58%
eslint-plugin 🟡 89.88% 🟡 83.33% 🟢 100.00% 🟢 97.90%
events 🟢 98.66% 🟢 94.59% 🟢 100.00% 🟢 100.00%
examples 🟢 95.68% 🟡 77.41% 🟢 100.00% 🟢 99.23%
iam 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
lambda 🟢 97.82% 🟢 96.19% 🟢 100.00% 🟢 98.27%
logs 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
module-compat 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
neptune 🟢 97.14% 🟡 86.36% 🟢 100.00% 🟢 98.50%
route53 🟢 98.24% 🟢 96.04% 🟢 100.00% 🟢 99.04%
s3 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
ses 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
sns 🟢 100.00% 🟢 100.00% 🟢 100.00% 🟢 100.00%
sqs 🟢 100.00% 🟢 98.55% 🟢 100.00% 🟢 100.00%
Total 🟢 98.07% 🟢 94.63% 🟢 100.00% 🟢 99.34%

A single local createNodes plugin cannot replace the per-package nx.targets
blocks: verified on nx 23 that workspace plugins load before the built-in
package-json inference and last-wins on merge, so a plugin's nx:run-commands
targets are clobbered by the built-in nx:run-script inference; and
targetDefaults cannot override an inferred target's executor. Only a package's
own nx.targets overrides its inferred script targets. Document this so the
duplication isn't mistaken for accidental and the plugin dead-end isn't retried.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEDVnuJAM7zgUUgLNnxn9j

laazyj commented Jul 23, 2026

Copy link
Copy Markdown
Owner Author

How this flake relates to the npm "empty package.json" bug (npm/cli#8425)

Context for readers wondering why the CI error looked like an npm/config problem.

The visible error is a generic secondary error. call config.load() before reading values is thrown by @npmcli/config whenever npm reads a config value after config.load() aborted before completing — it masks the real primary cause. npm/cli#8425 documents the static way to trigger that abort: an empty/invalid package.json in npm's inspection path makes load() throw. Our CI failure is the same "load aborted" failure mode surfacing the same secondary error.

Theory for how CI reaches that abort — a transient version of #8425. nx build runs npm run build for ~21 packages in parallel, and each build is tshy, which truncates-and-rewrites that package's package.json to manage its exports map (verified: build bumps the file's mtime; tshy's README: "The exports field in your package.json file will be updated"). A file mid-rewrite has a microscopic window where a concurrent reader sees it empty/half-written. If an npm process's config.load() reads a package.json in that window, it hits exactly #8425's condition — a momentarily-empty manifest — and aborts. So #8425 = statically empty; CI = momentarily empty because tshy is writing. Same fault, different way of producing the empty file.

Honest uncertainty. For npm run <script> with cwd = packages/X, config resolution inspects X's manifest plus ancestors to the root — X's own file isn't rewritten while its non-build tasks run (they dependsOn build), and the root manifest isn't tshy-managed. For the race to explain the observed failure (eslint-plugin:typecheck, whose own package.json is tsc-built, not tshy — with core/custom-resources/cloudformation tshy builds in flight at that same second), npm's startup would have to read a sibling manifest a concurrent tshy was rewriting, and I couldn't confirm from here that npm reads workspace siblings during config load (CI's npm 11 isn't a local dep, and nx logged only the secondary error, hiding the primary). So confidence: high it's an aborted config.load() (same class as #8425); medium the trigger is the tshy-rewrite race specifically; it's possible CI shares only the symptom and the real trigger is different transient contention during npm startup (perhaps aggravated by the npm install -g npm@11 "Pin npm" step swapping the npm install right before the run).

Why this doesn't change the fix. This PR is deliberately trigger-agnostic: running tsc/eslint/vitest/tshy directly removes npm — and therefore config.load() — from the task-execution path entirely, so whatever makes npm's config load abort simply isn't on the path anymore. The direct tools don't share @npmcli/config's brittleness toward a transient empty manifest. tshy still rewrites package.jsons during build, but nothing fragile races those writes after this change.

Getting certainty on the exact trigger would mean reproducing with npm at --loglevel silly (to catch the primary error before the secondary masks it) on a loaded Node-22 runner — worth it as curiosity, not as a blocker, since this PR removes the failure mode either way.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: intermittent task failure — nx runs package scripts via npm run, and npm's config loader crashes under parallelism

2 participants