Skip to content

feat(plugin-js): JS/TS plugin — install, import-graph, typecheck/test/lint/bundle drivers, bin-e2e - #394

Open
raphaelvigee wants to merge 70 commits into
masterfrom
raphaelvigee/feat-plugin-js-m8-bin-e2e
Open

feat(plugin-js): JS/TS plugin — install, import-graph, typecheck/test/lint/bundle drivers, bin-e2e#394
raphaelvigee wants to merge 70 commits into
masterfrom
raphaelvigee/feat-plugin-js-m8-bin-e2e

Conversation

@raphaelvigee

Copy link
Copy Markdown
Member

Combines the full plugin-js stack (previously #361#362#363#364#365#292#367#370) into a single PR rebased on master, per request. Those 8 PRs are closed; their branches are deleted; all 45 commits are preserved here, unchanged in content, just retargeted.

Milestones in this PR

  1. feat(plugin-js): scaffold JS/TS plugin, hermetic js_install driver #361 — scaffold JS/TS plugin, hermetic js_install driver
  2. feat(plugin-js): oxc-based import-graph resolver, phantom-dep detection #362 — oxc-based import-graph resolver, phantom-dep detection
  3. feat(plugin-js): js_typecheck driver, per-package tsc #363js_typecheck driver, per-package tsc
  4. feat(plugin-js): js_test driver, per-test-file granularity #364js_test driver, per-test-file granularity
  5. feat(plugin-js): per-package ImportGraph cache, js_lint driver #365 — per-package ImportGraph cache, js_lint driver
  6. feat(plugin-js): js_bundle driver, esm/cjs + node/browser variants #292js_bundle driver, esm/cjs + node/browser variants
  7. feat(bench): add JS/TS Tier B scenarios, go/js/both selector #367 — JS/TS Tier B bench scenarios, go/js/both selector
  8. test(bin-e2e): dlopen the shipped js plugin cdylib for real #370bin-e2e: dlopen the shipped js plugin cdylib for real, plus (post-test(bin-e2e): dlopen the shipped js plugin cdylib for real #370, folded in during this collapse) the peer-dependency lockfile fixes, js_test CI=1 + subprocess deadline kill, and the js_src codegen-input label

Rebase note

Rebased cleanly onto master (6 commits ahead at the time: kache/R2 cache backend, static Go linking by default, oci_load ref changes, build-version stamping, CARGO_TARGET_DIR override removal). Two conflicts, both mechanical:

  • .github/workflows/heph.yml — master added a kache stats + drain uploads step that must run last in the build job; this stack added an Upload js plugin artifact step. Resolved by placing the js-plugin upload before the kache drain step, preserving both.
  • devenv.nix — master replaced direct $CARGO_TARGET_DIR reads with a target-dir helper script across every devenv script. Resolved by adopting the helper in the e2e script's js-plugin additions rather than keeping the old $CARGO_TARGET_DIR reference.

cargo build --workspace --locked verified clean post-rebase.

raphaelvigee and others added 28 commits August 17, 2026 10:52
First-party JS/TS ecosystem plugin (M0+M1), mirroring the plugin-go /
plugin-go-cdylib two-crate split (logic crate + thin stabby cdylib wrapper).

- Provider: package.json discovery, pnpm-workspace.yaml + npm "workspaces"
  member resolution.
- js_install: hermetic per-(name,version,integrity) dependency fetch, SRI
  verification, platform baked into the cache key, postinstall scripts off
  by default with an explicit allowlist enforced before any network I/O.

Reviewed by feature-quality/code-quality/hermeticity; a tar-slip symlink
escape and an uncacheable (host-toolchain-dependent) lifecycle-script hash
were found and fixed, each with a regression test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
Temporary, will be reverted once the real cause is identified. Prints
what `cargo update` would change in CI's actual environment before the
--locked build, since every local reproduction attempt (native darwin,
explicit --target, full --workspace, cleared cache forcing a fresh
index) succeeds while CI fails identically and fast on every retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
…ke --locked builds

Revert the temporary CI diagnostics now that the root cause is confirmed.
M2 of the JS/TS plugin: fixes the deferred optionalDependencies hard-fail
(platform-restricted optional deps now silently skip instead of failing
Provider::get), then adds a real import-graph resolver on top of M1's
package.json-declaration wiring.

- oxc_parser + oxc_resolver extract and resolve import/require/dynamic-import
  specifiers per real Node condition-set semantics (separate ESM/CJS/types
  resolvers), building two distinct graphs (runtime vs type-only edges).
- Phantom-dependency detection: an import resolving into a package not in the
  declared-dependency closure (deps + devDeps + peerDeps) is a hard error
  naming the file/specifier/package, hermetic against a fresh checkout (no
  ambient node_modules required) via a bare-specifier name check.
- Conformance corpus (crates/plugin-js/src/pluginjs/conformance.rs) covering
  exports-map condition ordering, wildcard specificity, array fallbacks,
  null-blocked subpaths, self-referencing imports, and the "imports" field —
  cross-checked live against a real Node binary when present, self-gated
  otherwise.

Reviewed by feature-quality/code-quality/hermeticity. Four BLOCKERs found and
fixed: the phantom-dep check being a no-op without ambient node_modules,
peerDependencies never counted as declared, an oxc_resolver default reading
the ambient NODE_PATH env var, and a resolved-but-unclassifiable edge
silently passing instead of failing closed. Each has a regression test.

Explicitly deferred (named, not silent): import-equals require() and
require.resolve() extraction, per-specifier `type` modifier detection,
provider-lifetime resolver caching (currently rebuilt per Provider::get
call — a stated perf follow-up), and pinning a hermetic Node toolchain so
the conformance corpus's live cross-check is guaranteed to run in CI rather
than opportunistically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
…rde_json's Map ordering

oxc_resolver (a new plugin-js dependency) requires serde_json's
preserve_order feature unconditionally, which is a workspace-wide Cargo
choice. That silently flipped serde_json::Map from an always-sorted
BTreeMap to an insertion-order IndexMap everywhere, including in
plugin-oci's manifest/config json! literals, which relied on that
implicit sort for canonical, cache-key-stable output.

Sort explicitly at encode time instead of depending on which Map
backing happens to be active.
…uirk that changed after v18

array_exports_matched_entry_missing_on_disk_hard_fails_no_fallback's live
cross-check was authored against v18.12.1's --experimental-import-meta-resolve.
Later Node no longer checks file existence for array-form exports entries
during resolution, deferring it to module load — so any newer Node found on
PATH now resolves where the hard-coded expectation says it should throw.

plugin-js's own resolver (the assertion that actually matters here) still
implements the documented algorithm correctly and is unaffected; only the
live Node cross-check is dropped for this one fixture.
M3 of the JS/TS plugin: a cacheable js_typecheck ManagedDriver running
tsc --noEmit per package, fed by the M2 import graph rather than blind
package.json declarations.

- Toolchain: tstool=host (only supported mode) resolves tsc from
  node_modules/.bin or PATH, queried once per Provider lifetime and hashed
  alongside the tsconfig content — disclosed non-hermetic escape hatch,
  same shape as the design doc's stated M1+ gap.
- Input scoping fixed through review to actually match what tsc reads:
  plain (non-type-only) cross-package imports, not just `import type`;
  third-party .d.ts inputs resolved via the same lockfile mechanism as
  js_install (works with no ambient node_modules, not just when one
  happens to exist on disk); tsconfig include/exclude honored for
  first-party sources; the full tsconfig extends chain declared and
  hashed; a shared/ancestor tsconfig with unscoped include is now a loud
  Provider::get error instead of a silently unsound cache key.

Reviewed by feature-quality/code-quality/hermeticity — five BLOCKERs found
(all variations on "the declared Input set doesn't match what tsc actually
reads") and fixed, each with a regression test proving the specific
divergence. Tests requiring a real tsc binary are #[ignore]d with a named
reason rather than silently skipping and reading as a pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
M4 of the JS/TS plugin: a cacheable js_test ManagedDriver with one target
per test file (vitest default, jest alt via a single testrunner config
option), fed by the M2/M3 import graph rather than package-level caching —
the differentiator the design doc calls out over Turborepo/Nx, which never
get finer than per-package task caching.

- Per-test-file Input scoping: the test file's own runtime-transitive
  closure (BFS over ImportGraph::runtime_edges, bounded to the owning
  package), third-party deps resolved via the same lockfile-driven
  mechanism js_install/js_typecheck use (no ambient node_modules
  dependency, per the M3 lesson), and the resolved test-runner config.
- Runner-config discovery covers vitest's own fallback (vite.config.*) and
  jest's package.json "jest" field, not just the dedicated config
  filenames.

Reviewed by feature-quality/code-quality/hermeticity. Three BLOCKERs found
and fixed: a js_test addr's file= argument accepted an absolute path or a
`..`-escape with no validation, letting a target read/exec outside the
workspace and sandbox entirely (fixed at both Provider::get and
defensively again in the driver's run()); vitest/jest config-referenced
files (setupFiles, globalSetup, a shared base config reached via a
relative import) were untracked, so editing one didn't bust the cache of
every test that depends on it; and the primary runner-config file itself
could go undiscovered for the vite.config.*/package.json-jest-field
layouts. Each has a regression test.

Explicitly deferred (named, not silent): per-test-file Provider::get
rebuilds the whole package's import graph from scratch rather than
caching it once per package (a real warm-cache-path cost); the
cross-package one-hop trim now risks a stale test-pass cache hit, not
just a missed diagnostic, for a barrel-file re-export; test-runner-specific
alias resolution (vitest resolve.alias, jest moduleNameMapper) isn't fed
into the resolver.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
M5 of the JS/TS plugin.

Perf fix (prerequisite): deps_config, typecheck_config, and test_config
each rebuilt the whole package's ImportGraph from scratch on every
Provider::get call — for js_test, once per test file. Flagged independently
by both the M2 and M4 reviews and deferred twice. Added a per-package
memoized cache (keyed OnceCell behind a Mutex whose critical section is
just the get-or-insert, so unrelated packages never serialize behind one
lock) shared by all import-graph consumers, plus a call-count test proving
it actually memoizes. Also added the equivalent cache for workspace-member
discovery, the same O(P) redundant-walk shape found alongside it.

js_lint: cacheable per-package target, oxlint default + eslint (with
type-aware parserOptions.project support) via a single linter config
option — same naming rule as every other driver here. Reuses the lockfile-
driven third-party resolution and tsconfig-extends-chain handling already
established for js_typecheck/js_test.

Reviewed by feature-quality/code-quality/hermeticity — five BLOCKERs found
and fixed: a fabricated package.json-config fallback that doesn't match
how oxlint/eslint actually discover config and would break real
invocations; an unconditional hard-fail on packages with zero lintable
source files (now a clean no-op); only the first parserOptions.project
entry in a multi-entry eslint config being tracked, silently dropping the
rest from the cache key; an unvalidated parserOptions.project path letting
a repo-controlled eslint config make heph read and hash an arbitrary host
file (e.g. /etc/hostname) with no workspace-containment check — a real
escape, now a hard error instead of a silent fallback; and eslint configs'
own relative-path extends/imports (a shared base config) going untracked,
the same class of gap already fixed once for js_test's runner config.

Explicitly deferred: converging graph_cache/tsc_cache/testrunner_cache/
linter_cache onto the repo's existing hmemoizer primitive for panic
containment across the ABI seam (pre-existing pattern, not a new
regression, scoped as its own follow-up); a same-package concurrent-race
test for the new cache (single-flight correctness argued from tokio's
OnceCell contract, not yet proven under real concurrency); no bin-e2e
coverage for any JS driver's dlopen/ABI-crossing seam yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
M6 of the JS/TS plugin, the final milestone: a cacheable js_bundle
ManagedDriver wrapping esbuild, with format (esm/cjs) and target
(node/browser) as plain addr args resolved with a flat default rather
than porting the Go plugin's ancestry/universe variant machinery — this
plugin's addr model is a single flat workspace, so there's no ancestor
chain or cross-subtree variant-pin problem for that machinery to solve
(researched and confirmed against crates/plugin-go/src/plugingo/variant.rs
and this crate's own flat workspace.rs model before deciding).

Whole-graph cache key by design (the entry point's full transitive
closure via ImportGraph::runtime_edges, cross-package recursion unlike
js_test's one-hop trim), third-party deps resolved via the same
lockfile-driven mechanism as every other driver, tsconfig (plus its
extends chain) declared and staged the same way js_typecheck does.

Reviewed by feature-quality/code-quality/hermeticity. Two functional
BLOCKERs found and fixed: the discovered third-party import closure was
never wired into esbuild's --external flags, so bundling any package with
a real npm runtime dependency failed outright; and the output directory
didn't vary by format/target, so esm and cjs variants of the same package
collided on the same declared output path. A third BLOCKER (missing
tsconfig Input) meant path-aliased/JSX/decorator-configured TS entry
points silently failed or cached wrong. All three fixed with regression
tests, including a real-esbuild end-to-end proof for the external-deps fix.

Explicitly deferred (disclosed in module docs): target=browser doesn't
yet change what's resolved (only what esbuild is told to assume) since
the resolver has no browser condition/main-field support; a
config-reference-scanning helper shared with js_test/js_lint reads a
config-referenced file before checking workspace containment rather than
after (dormant for js_bundle's only supported config format today, live
for js_test already, flagged as a cross-cutting follow-up not specific
to this milestone); rollup/webpack/vite bundlers.

Note for follow-up: the design doc's v1 scope table lists six drivers
including js_format, but the Milestones list only ever defined M0-M6 for
the other five (install/typecheck/test/lint/bundle) — js_format has no
milestone and isn't built. Flagged in plugin-js-cdylib's module doc; needs
either an M7 or a corrected scope table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
Speed was the plugin's stated #1 design goal from the start
(ai-docs/js-plugin-plan.md) but had never actually been measured. Adds
#[ignore]d ad-hoc benches (never run under tst/cargo test by default) in
importgraph.rs measuring Provider::import_graph's cold cost (first
parse+resolve per package) vs warm cost (M5's per-package memoization) on
synthetic workspaces of 20 and 150 packages, plus a parse-vs-resolve phase
split.

Results (darwin/arm64, this machine only — not transferable to other
targets without re-running there): cold ~2.5-3ms/package, ~0.16-0.2ms/file;
warm ~500ns total across 150 packages (cache hit never re-enters the build
path, confirming M5's fix delivers as designed). Resolution (oxc_resolver)
is 97%+ of cold cost, parsing (oxc_parser) is noise by comparison.

No regression baseline existed before this, so this is establishing one,
not comparing against one. Flagged as a real gap: no JS/TS scenario exists
in crates/bench/crates/bench-corpus (Go-only today), so this path has no
standing CI perf-regression coverage — building that out is the natural
follow-up, left for later rather than folded into this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
Extends heph-bench to measure the new plugin-js the same way it already
measures plugin-go, and generalizes Tier B (dist.rs) across languages
instead of duplicating the go-specific path.

- crates/bench-corpus: generate_js_tree produces a synthetic, network-free
  pnpm workspace (zero third-party deps by design — no js_install fetch,
  no lockfile, fully hermetic) with the same layered-DAG shape the bash
  and go generators already use. Wired via js_packages/js_max_depth,
  mirroring go_packages/go_max_depth exactly; 0 (the default) is a no-op,
  so existing callers get byte-identical output.
- crates/bench: dist.rs rewritten around a Lang abstraction (GO/JS
  consts: name, provider-option fragment, package-count accessor,
  incrementalize fn) instead of two near-duplicate go/js code paths.
  `run dist --lang go|js|both` (default go, preserving existing-caller
  behavior exactly). `both` reports each language as its own distinct
  ScenarioResult ("<scenario>-go"/"<scenario>-js") — RunResults already
  carried a Vec of scenarios for exactly this, so results are never
  silently summed/averaged across languages.
- The `corpus` CLI subcommand was missing --js-packages/--js-max-changed
  entirely (bench-corpus's library supported them, main.rs never exposed
  them) — added, mirroring --go-packages/--go-max-depth.

Found and fixed a real, pre-existing bug while mirroring the go path for
js: Tier B's `heph r build //<lang>/...` invocation is the two-positional
form, which parses as `label("build") && //<lang>/...` — no BUILD file in
the corpus sets that label, so it has always matched zero targets and
exited 0, silently measuring an empty build. This affected the existing
go benchmark too, not just the new js one. Fixed to the query form
(`heph r -e '//<lang>/...'`), verified against a real binary (built a
fixture, confirmed the old form reports "matched 0 targets" and the new
one actually executes targets), with a regression test that fails if the
matcher regresses back to the broken form.

Artifact pipeline (a real decision, not a side effect): plugin-js-cdylib
is now built and published by heph.yml's `build` job alongside
plugin-go-cdylib/plugin-gha-cdylib, in the same pre-release artifact
bundle. No public heph-js-plugin.json manifest is generated (unlike
go/gha) — bench builds its own local manifest from the raw dylib, same as
it always has for go; declaring the plugin generally installable is a
separate, larger decision left unmade. devenv.nix's `e2e` script
deliberately does NOT build plugin-js-cdylib — there is no
shipped_js_cdylib_loads bin-e2e test yet to justify the build cost,
mirroring the existing lint-but-don't-e2e-test asymmetry.

perf.yml wires --lang js into the perfbench job's Tier B step, gated
best-effort on the baseline release actually having a js-plugin asset
(older releases won't). Known, disclosed gap: no oxlint/esbuild toolchain
is provisioned on any CI runner yet, so the JS Tier B comparison reports
"no results" until one is — the target-matcher fix above makes that
failure mode honest (real attempt, real failure) rather than the
previous silent zero-targets false success.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
Makes the js plugin generally installable the same way go/gha already
are: plugins: - { identifier: { url: https://.../heph-js-plugin.json } }
now resolves for any real workspace, not just this bench pipeline's own
locally-built manifest (crates/bench/src/dist.rs's write_dist_config,
unaffected — it still builds its own manifest straight from the raw
dylib and never reads this one).

Generated the same way as go/gha: tools/pluginmanifest against the
per-os/arch heph-js-plugin_<os>_<arch>.<ext> release assets the `build`
job already publishes (wired in the prior commit on this PR), with the
same URL-base/checksum-sidecar shape. perf.yml is unaffected — it
downloads only the raw dylib, not this manifest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
Closes a gap flagged repeatedly since M0: plugin-js-cdylib was lint/fmt
covered (devenv.nix's qualityCrates) but never actually loaded across
the real ABI seam by any test. Wires it into the e2e devenv script as a
fourth staged artifact (symmetric with go/gha in both the local-build and
HEPH_E2E_FROM/CI branches), and adds four real bin-e2e tests proving it:

- shipped_js_cdylib_loads_and_answers_across_the_abi: construction +
  the sync inspect functions ABI call round-trips cleanly. The js
  Provider never overrides functions() (falls through to the trait's
  empty-vec default, unlike go's build_addr), so this asserts no line
  in `inspect functions` output starts with "js." rather than asserting
  empty stdout outright -- the fs provider's own functions are always
  registered regardless of plugin config, so an empty-stdout assertion
  is simply wrong (caught by review, the first draft failed
  deterministically).
- shipped_js_cdylib_discovers_a_real_workspace_package: query -e //...
  against a real pnpm-shaped fixture resolves //packages/foo:package_info
  -- proves Provider::list/list_packages cross the seam intact.
- shipped_js_cdylib_resolves_pnpm_workspace_glob_membership: two real
  package.json files, one inside the packages/* glob and one deliberately
  outside it declared as a dependency; resolving the in-glob package's
  config must fail on the unresolved dependency, proving the excluded
  package was never admitted as a workspace member. Added because the
  discovery test above (despite its own doc comments originally claiming
  otherwise) never actually exercises workspace.rs's glob-membership
  resolution -- Provider::list discovers by package.json presence alone,
  independent of pnpm-workspace.yaml. Caught by review; the stale
  overclaiming comments were corrected rather than left standing.
- js_plugin_construction_failure_logs_before_the_abort: the same
  log-sink-before-abort regression coverage the go test already has,
  ported for js's identical construction-failure shape.

Verified for real: ran the full `e2e` devenv script (release build of
heph + all three plugin cdylibs, staged, macOS-portable-patched) end to
end -- 17/17 tests passed across the whole bin-e2e suite, all four new
plugin_dylib_js.rs tests included, against real dlopen'd release
artifacts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3wZfyPsG8stfRQuybLRjN
…ot phantom

check_phantom_dependencies only accepted a package's own direct
dependencies/devDependencies/peerDependencies, hard-failing on the
extremely common real-world pattern of importing a companion package
(e.g. `@eslint/js` from eslint.config.js) that isn't itself declared
but is a genuine transitive dependency of something that is (e.g.
typescript-eslint), per the lockfile.

transitive_declared_closure widens the accepted set by walking the
lockfile's resolved dependency graph outward from the manifest's own
direct deps. Since the lockfile is already a hashed input, this stays
exactly as hermetic as accepting a direct dependency — unlike relying
on ambient node_modules hoisting, which is what the check exists to
reject in the first place.

A hermeticity review caught that widening the check alone would let
Provider::get succeed while deps::resolve_one_dependency still failed
to wire an Input for the same package (deterministically on pnpm,
narrowly on npm under nested hoisting) — reopening the exact
same-source-different-host non-determinism this subsystem exists to
close. Fixed by unifying both paths onto one shared primitive
(lockfile::resolve_transitive / ResolvedGraph::transitive_reachable),
so the phantom-check's accepted set and the Input-wiring resolver's
reachable set can no longer diverge.
…_test's sandbox

test_deps_config only fed the test file's own transitive import closure
(closure.bare_specifiers) into the third-party js_install Input list.
A test-runner config (vitest.config.ts/jest.config.ts) is a separate
root the runner loads independently — nothing in the test file imports
it, so its own plugin imports (@vitejs/plugin-react,
@rolldown/plugin-babel, @lingui/vite-plugin, vite-plugin-svgr,
@vitest/browser-playwright, ...) were never staged in the sandbox at
all, even when properly declared in package.json.

resolve_runner_config_referenced_files now also collects (without
following) bare specifiers encountered while scanning the config and
any relatively-imported base config in its chain. test_deps_config
feeds them through the same declared-dependency check and
deps::resolve_one_dependency wiring closure.bare_specifiers already
gets — necessary in its own right, not just for consistency: a shared
runner config commonly lives outside the package's own directory
(found via find_nearest_test_runner_config's ancestor walk), so it is
never part of the graph check_phantom_dependencies already validates.

Known related gap, left open rather than silently claimed as covered:
js_lint's eslint_plugins field only scans a flat config's own leaf
file for plugin imports, not a relatively-imported base config's —
documented at resolve_eslint_config_referenced_files's call site.
…r; fix runner-config bare-specifier file paths

The 'declared in package.json but has no lockfile resolution' error
couldn't distinguish 'no lockfile was loaded at all' (a wrong or
absent workspace_root) from 'a lockfile loaded but has no entry for
this name' (genuinely stale) — both produce the same None. Now names
which one, and how many packages a loaded lockfile actually parsed.

Also: BareSpecifierSite::file for a runner config's own bare imports
was left as an absolute path (resolve_runner_config_referenced_files
has no workspace_root to convert against internally) instead of
workspace-relative like every other BareSpecifierSite in this crate —
fixed at the test_deps_config call site. Diagnostic-only (the file
field is never used to resolve anything, only to name the source in
an error), but confusing and inconsistent.
A heph workspace can contain more than one independent npm/pnpm
project (each with its own lockfile) nested at different points, not
just one shared at the configured workspace root. Provider::lockfile
now ancestor-searches from each package's own directory for the
nearest lockfile, the same walk-up-by-presence pattern already used
for tsconfig/eslint/bundler-config discovery, instead of assuming a
single fixed lockfile path.

Provider::find_resolved_graph_for resolves a bare (name, version)
thirdparty addr against every discovered root and fails loudly on a
genuine disagreement (full ResolvedPackage equality, not just
integrity — resolved feeds JsInstallDef's hash too) rather than
silently picking one, since thirdparty_addr's scheme has no per-project
scoping. The check always walks the full workspace, not only when the
per-Provider cache is empty: a cache hit for one root says nothing
about a second, not-yet-resolved root that could disagree, and
skipping the walk whenever any match is already cached would make the
check order-dependent on the engine's own incremental resolution
order. The walk's own root list is cached once per Provider lifetime
to keep this cheap across repeated resolutions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
group's relocated (strip_prefix/prefix/rename) output has always lived
only inside heph's own build graph/sandbox, with codegen_tree hardcoded
to None. Add an opt-in `codegen = "copy"` config field (mirroring
pluginexec's identical field) so a relocating group can also write its
result onto real disk via heph's existing codegen write-back path,
needed by a plugin-js consumer that materializes a resolved
node_modules tree for IDE visibility.

`in_place` is rejected: a relocating group's inputs are `deps` addrs,
never `@heph/fs` file addrs, so it has no way to declare the
overwritten path as one of its own hashed inputs the way `in_place`'s
staleness guard requires elsewhere. `codegen` on a non-relocating
(aggregate, transparent) group is also rejected — it has no artifact
of its own to materialize.

Every existing group(...) call site is unaffected: codegen defaults to
None, and the def hash (already hand-computed, not a generic spec
serialization) only folds it in when relocating, so an ordinary
aggregate group's hash is unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
js_install's download has always lived at a synthetic path
(@heph/js/thirdparty/<name>@<version>) nothing ever relocated to
<consuming_pkg>/node_modules/<name> — the only path Node's own module
resolution ever looks at. Confirmed via live user testing: every
resolved third-party addr was correctly declared as an Input, but the
sandbox's real node_modules never had the files, so every js_test run
touching a third-party package failed with "Cannot find module".
ai-docs/js-plugin-plan.md's "Per-target node_modules reconstruction"
called this out as required for v1; it was simply never implemented.

Fix: reuse the existing group driver as a zero-copy relocation
primitive rather than inventing a new mechanism. deps::
resolve_one_dependency now returns a thirdparty::node_modules_addr
(package @heph/js/node_modules, target "group", every variable part —
consuming_pkg/local_name/resolved_name/version/goos/goarch — as an
Addr arg, never folded into the package string, since a consuming
package nested under an @scope-looking directory makes positional
encoding ambiguous) instead of js_install's raw addr.
Provider::node_modules_group_spec synthesizes the group's config
(strip_prefix = js_install's synthetic package path, prefix =
<consuming_pkg>/node_modules/<local_name>). local_name (what the
consumer's own source/config names) and resolved_name (what
js_install actually downloads) are kept distinct throughout since an
npm/pnpm alias makes them diverge.

This changes the Input addr every js_test/js_typecheck/js_bundle/
js_lint/package_info target wires for any third-party dependency, so
it invalidates the cache for all of them — expected and necessary,
since the old addr keyed a broken (unreachable) artifact.

Also fixes a second, independent bug in the same area:
bundle_closure_step, typecheck_deps_config, and test_deps_config each
classified an import edge landing inside a real ambient node_modules
(only possible when one happens to exist on the host) the same as a
genuine first-party file, wiring it as a raw fs:file Input at that
ambient path — a build depending on host filesystem state no input
hashes, and never even reached on a fresh checkout. All three now
share one classify_resolved_edge helper that routes a node_modules-
landed edge through the same lockfile-driven resolution instead.

Extends relocation to the full transitive third-party closure (not
just directly-imported names) for js_test/js_typecheck via
deps::resolve_transitive_closure, walking ResolvedGraph::
transitive_reachable from each package's own direct deps — a resolved
package's own internal dependencies (axios needing follow-redirects)
need the same node_modules materialization a directly-imported name
gets, or the real test/typecheck run hits the identical failure one
edge deeper. js_bundle is deliberately excluded: a bundler marks
third-party names --external and never descends into their own
requires at build time. js_lint is deliberately excluded for now: it
resolves plugin/extends names from config text, not an import graph.

Finally, adds a NODE_MODULES_SYNC_TARGET ("node_modules") per package:
an aggregating group (identity transform, codegen = "copy") over every
relocated dependency a package resolves. Nothing else in the graph
depends on it — codegen write-back only fires for a directly-requested
top-level target, so `heph run //pkg:node_modules` is the only way to
get a hermetically-resolved dependency onto real disk where an IDE can
see it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…check

find_resolved_graph_for's cross-lockfile ambiguity check compared every
discovered root's entry for a shared (name, version) unconditionally,
including a root whose own lockfile records that entry with empty
integrity and no resolved URL — a real, observed npm lockfile shape
(a degenerate/deduped graph-key record, not a genuine resolution), not
a hypothetical. Comparing it against another root's actually-resolved
entry produced a false-positive "resolves to different content" hard
error, live-confirmed across hundreds of packages at once in a real
multi-project workspace: every package one project's degenerate
lockfile record happened to share a name with, resolved anywhere else
in the workspace.

An empty-integrity entry never pinned any content to disagree with —
it can't confirm or deny a match. Only entries that actually carry
integrity participate in the comparison now; fewer than two such
entries means nothing to disambiguate, so resolution succeeds via
whichever root has real content. Two roots that both genuinely
resolved the package, with real but differing content, still fail
loudly exactly as before — this narrows what counts as a competing
resolution, not what counts as a conflict.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…egrity

A hermeticity review caught a more insidious version of the bug the
previous commit fixed: dropping an empty-integrity entry from the
ambiguity comparison entirely (not just skipping the integrity field)
meant a root with empty integrity but a real, genuinely different
resolved/os/cpu/dependencies/has_install_script would silently pass
unchecked — using an unrelated project's resolved URL for that root's
own dependency, with no error, since the winning entry's own
integrity still verifies fine against its own bytes.

find_resolved_graph_for now compares every discovered root's entry
against the chosen "winner" via entries_agree_where_comparable, which
exempts integrity from comparison only when one side is empty, and
resolved only when one side is None — mirroring how a genuinely
degenerate lockfile entry naturally lacks both, not just one. Every
other field, and either of these two when both sides actually
populate it, still compares exactly as before. Also sorts matches by
root path before picking, so which root's data appears first in an
error (or gets silently picked in the fully-degenerate all-empty case)
no longer depends on HashMap iteration order.

New regression test proves the caught gap is closed: empty integrity
paired with a real, differing resolved URL still hard-fails. The
already-shipped false-positive fix (empty integrity vs. a fully
resolved entry) and both pre-existing genuine-conflict tests still
pass unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
Live-confirmed right after the previous two fixes shipped: two
independent, unrelated projects both genuinely resolving the
*identical* published package (byte-identical integrity and resolved
URL) still hard-failed as "ambiguous" — hundreds of times in one real
workspace — because each project's own npm install ran at a different
time, and the shared package's own transitive dependency resolved to
a slightly different patch version. entries_agree_where_comparable
was comparing `dependencies` (and `os`/`cpu`) unconditionally, but
none of these three fields are part of js_install's own cache key
(driver_install.rs's JsInstallDef: name/version/integrity/resolved/
goos/goarch/has_install_script/scripts_allowed only) — dependencies is
pure graph-traversal bookkeeping about *other* packages, and os/cpu
only gate whether an install applies to the current platform
(checked separately, downstream, against whichever root this
function returns) — comparing either was never correct.

The comparator now only compares integrity, resolved, and
has_install_script — exactly what determines js_install's actual
output — with the pre-existing per-field empty/None exemptions
unchanged. New regression test proves two roots with identical
integrity/resolved but differing dependencies now resolve without
error; all four pre-existing ambiguity tests (genuine integrity
conflict, genuine resolved conflict, empty-integrity exemption, empty-
integrity-with-real-diverging-resolved-still-conflicts) still pass
unchanged.

Also fixes an unrelated logging bug spotted in the same live output:
result.rs's "get def for group" error context Debug-formatted the
TargetAddr instead of using its own Display impl (already used one
line above for the identical value), printing a multi-line struct
dump instead of the addr string.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
NpmLockfile::resolved_graph() and PnpmLockfile::resolved_graph() built
their (name, version) -> ResolvedPackage map with a blind BTreeMap
insert-on-collision. npm's own dedup/hoisting can leave a nested
node_modules path with no integrity/resolved of its own alongside the
real, genuinely-resolved entry for the same package elsewhere in one
lockfile (pnpm: peer-suffix variants of one version collapse the same
way). Iteration is path/key-alphabetical, not "real entry first", so
whichever happened to sort last silently won — live failure was
js_install erroring "no recognized integrity algorithm in \"\"" for a
package the lockfile actually did pin correctly.

Fix: on a graph_key collision, prefer whichever entry has non-empty
integrity, and hard-fail via entries_agree_where_comparable (moved out
of provider.rs, now shared) when two non-degenerate entries actually
disagree — the same fields/reasoning as the cross-lockfile-root
ambiguity check, since a silent pick there would be exactly as wrong.
resolved_graph() is now fallible; threaded through the OnceCell-cached
wrapper in provider.rs via get_or_try_init.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…here

Live testing turned up a fourth failure mode, distinct from the prior
three ambiguity/collision fixes: some packages (ts-log,
ts-interface-checker, tailwindcss-scoped-preflight, @types/react in a
real workspace) have exactly one lockfile entry, and it has no
integrity/resolved at all — not a dedup/collision artifact. Root cause
confirmed against npm's own issue tracker: `npm install` (unlike `npm
ci`) can satisfy a package from its local cache and strip
resolved/integrity from an existing package-lock.json entry instead of
repopulating them — a known npm CLI bug (npm/cli#4263, #4460, #6301),
not a heph bug.

There's nothing safe to fetch+verify against in this case, so
js_install correctly still fails — but it did so with "no recognized
integrity algorithm in \"\"", surfaced deep inside the SRI parser with
no indication of cause. Move the check up to where the js_install spec
is built and name the actual cause plus the fix (regenerate the
lockfile).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
raphaelvigee and others added 27 commits August 17, 2026 10:52
Real failure: js_test crashed with `Error: Cannot find package
'playwright'`. vitest optionally depends on @vitest/browser-playwright
(walked fine via existing optionalDependencies handling), which
unconditionally imports playwright at module load despite declaring it
only as a peerDependencies entry. ResolvedPackage had no field for peer
deps at all, so transitive_reachable could never walk to a genuinely-
installed peer.

- ResolvedPackage gains peer_dependencies. npm resolves it via the same
  ancestor-node_modules walk already used for dependencies/optionalDependencies
  (npm's package-lock.json never writes a resolved edge for a peer, only
  a bare copy of the package.json range).
- transitive_reachable's flat and override passes both walk peer edges
  with the same platform-gated, miss-tolerant treatment as optional deps.
- provider.rs's lifecycle-script sibling-resolution walk (js_install
  postinstall requires) gets the identical peer-edge treatment — same
  defect class, different call site.

While verifying pnpm needed no equivalent change, found and fixed a
second, unrelated, pre-existing bug: pnpm's `packages:` section keys a
package with its own peerDependencies WITHOUT a peer suffix (confirmed
against a real `pnpm@9.15.9 install`), but `snapshots:` keys the same
package WITH one. resolved_graph() joined them on the same key, so the
lookup always missed for any such package — silently leaving ALL of
its dependencies empty, not just the peer edge, for any pnpm project
using a package like react-dom. Fixed by driving the graph from
snapshots (source of dependency edges) with metadata joined via the
stripped peer suffix.

Known, pre-existing, documented limitation (not introduced here, not
fixed): pass 1's flat-reachability BFS has no bias toward `dependencies`
edges over optional/peer edges for the same name — whichever is reached
first in seed/BFS order wins the flat placement. optional_dependencies
already shared this exposure; peer deps widen it since a peer's whole
purpose is naming a widely-shared library likely to also have a real
dependencies edge elsewhere. Sandbox materialization stays correct
regardless (pass 2 re-derives the right per-consumer placement via
nested overrides) — the residual risk is narrower: resolve_transitive's
single-name flat-only lookup, used only for a first-party import with no
declared edge of its own. Pinned with a test
(transitive_reachable_flat_placement_prefers_whichever_edge_type_is_seeded_first_not_dependencies)
rather than redesigned here — changing pass 1's priority is a separate,
cross-cutting design change affecting already-shipped optional-dependency
behavior too, not something to bundle into this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
heph's js_test invocation is headless by construction — no TTY, no
human able to answer a prompt or press a key — exactly what CI
conventionally signals to a well-behaved CLI tool. Defense-in-depth
alongside the existing explicit `run` subcommand (which already
defeats vitest's own default watch-mode selection): vitest and jest
both gate other interactive behavior (reporter live-redraw, keypress
handling) on isCI/isTTY checks beyond just watch-vs-run selection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…adline

CI=1 and the run subcommand only reduce the odds vitest wedges after
finishing its own work (a confirmed live upstream failure mode: a
failed dependency-optimizer scan leaving a background esbuild service
alive) — they don't bound it. proc_exec::output otherwise waits on the
child's own exit with no timeout at all, so a wedged runner hangs the
whole heph run forever with no distinguishing signal from "still
working".

DeadlineCancellable composes the caller's own cancellation token with a
deadline (fires on whichever comes first) rather than wrapping the
subprocess future in a bare tokio::time::timeout, which would just drop
the future and leak the orphaned child. Composing tokens instead routes
through proc_exec's existing SIGINT-then-grace-then-SIGKILL teardown —
the same path a real user cancellation already goes through — and the
original ctoken is never itself touched, so a target that merely timed
out is never mistaken upstream for a user-cancelled run.

Verified proc_exec::output actually errors (not a killed-but-Ok status)
once its cancellable fires, confirmed empirically rather than assumed,
and regression-tested against a real subprocess that actively ignores
SIGINT — the exact shape of a wedged child that won't respond to a
plain interrupt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…nput

Mirrors plugin-go's go_src mechanism: a BUILD author marks an ordinary
codegen target labels = ["js_src"]; each of the 4 JS driver Input-
computation functions (test/typecheck/lint/bundle _deps_config) now
queries "{scope} && label(js_src) && tree_output(pkg)" and wires the
result in as a declared Input, alongside the existing import-graph-
derived ones. Because it's a declared Input, the engine schedules and
runs the codegen target automatically before whichever target needs
it, and stages its output into the sandbox before the driver's
subprocess (vitest/tsc/eslint/esbuild) runs — no change needed to
importgraph.rs's own resolution walk, since the downstream tool
resolves the generated import itself once the file physically exists,
exactly as `go list` already does for go_src.

Fixes the real reported gap: a js_test importing GraphQL-codegen
output previously required a human to remember to run the codegen
target first.

Design reviewed in parallel by product-vision/hermeticity/feature-
quality before implementation (per this repo's review-board process).
Consensus: mirror go_src's mechanism, but two things needed resolving
first, both addressed here:

- Scope: go_src's *default* scope (no explicit root override) is
  already the package's subtree, not the bare package — go_src's own
  history is why: a bare-package floor shipped a real bug (an embed
  that resolved on some runs and failed on others, when a generator
  sits in a sub-package of the consumer). js_codegen_default_scope
  mirrors this exactly, confirmed sufficient for the reported case
  (codegen output lands inside the consuming package itself). No
  go_codegen_root equivalent (ancestor-scope-widening for codegen
  landing in a *different* package) yet — deferred, disclosed in
  js_src_query_addr's doc.
- Cost: js_test has per-*test-file* target granularity, not go_src's
  per-*package* granularity, so the query must resolve once per
  package and be shared across every test file and all 4 drivers, not
  recomputed per target. js_src_query_addr is a single pure function
  every call site routes through unchanged, so all 4 _deps_config
  functions format the byte-identical query addr for the same package
  by construction — the engine's own per-addr memoization does the
  rest. Also excludes the js provider from the query (mirroring go_src
  excluding go) — without it, every js_test/typecheck/lint/bundle
  candidate in scope would pay a full get_spec just to be rejected on
  the label check.

Disclosed, not silently inherited: a genuine dependency cycle through
a js_src-labelled target is silently dropped from the query match by
the engine's own existing query-cycle handling (the identical,
already-shipped go_src behavior, not new here) — no dedicated
diagnostic for this case yet.

Known gap, not yet covered: no in-process e2e test exists for this
(crates/e2e has no JS harness at all yet, a separate, pre-existing
infrastructure gap; real vitest also isn't available in this devenv).
Unit-tested at the provider layer: query construction (scope/label-
before-tree_output ordering/js-provider exclusion), and that all four
_deps_config functions wire the identical codegen addr for the same
package. Not yet covered: ported cycle-containment regressions
(plugin-go's own 5-test suite for this exact hazard class), failure
attribution when a js_src target itself fails, and output-collision
handling for two conflicting js_src targets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…eout

The RUNNER_TIMEOUT deadline (added earlier this session) was a working but
insufficient fix: it correctly killed a wedged vitest/jest, but only after
waiting the full 20 minutes even when the runner had actually finished and
printed results seconds earlier. Explicitly rejected as "not solving the
problem" — the driver needs to detect real completion and exit immediately.

Mechanism: vitest is now also invoked with `--reporter=default
--reporter=json --outputFile=<path>` (jest: `--json --outputFile=<path>`),
writing a structured, Jest-compatible result to an undeclared sandbox
scratch file (`.heph-js-test-result.json`, same pattern as _golist's
`.heph-gocache`). `exec_runner` polls for that file on its own dedicated
task and, the instant it parses, treats the run as over — verdict from the
JSON's `success` field, never the process's eventual exit code — and force-
kills the child via a manually-triggered cancellation token composed into
the same SIGINT->grace->SIGKILL teardown a real Ctrl-C already goes
through. RUNNER_TIMEOUT (20 min) remains only as a last-resort backstop for
a runner that produces neither a normal exit nor a parseable result file.
JS_TEST_FORMAT_VERSION bumped (invocation shape changed).

Went through a full 3-agent review board (hermeticity, code-quality,
feature-quality) in parallel, all findings acted on:

- BLOCKER (code-quality, empirically reproduced): the first draft raced
  the result-file poll against OutputReader::recv() in one tokio::select!
  loop. On macOS, recv()'s multi-threaded backend parks the task in
  block_in_place - never Pending - so the poll arm starves for as long as
  the runner stays silent, i.e. exactly the wedged-runner case this exists
  to fix. Reproducing under `flavor = "multi_thread"` (what every
  production entry point actually uses; bare #[tokio::test] defaults to
  current-thread and never hits this path) hung for 60s+. Fixed by moving
  the poll onto its own task, decoupled from stream draining - the same
  safe shape pluginexec and plugin-oci's docker_build.rs already use for
  this reader. All new tests that exercise a hanging fake runner now use
  `flavor = "multi_thread"` so a regression here fails loudly again.
- BLOCKER (feature-quality): the headline claim - "JSON verdict wins over
  exit code" - had zero coverage for a normally-exiting process; every
  test used a runner that hangs after writing the file, which only proves
  the mechanism via the kill path, never the plain post-EOF fallback.
  Added tests asserting the JSON verdict wins in both directions
  (success=false + exit 0, success=true + exit 1) against a runner that
  exits immediately.
- MAJOR (feature-quality): jest's entire run()/arg-building path (--json
  --outputFile=<path> insertion) was untested - every test defaulted to
  vitest. Added a jest-specific detection test.
- MAJOR (feature-quality): the stale-result-file guard (remove_file at the
  top of exec_runner, guarding a reused sandbox path) had no regression
  test. Added one that seeds a stale success file and confirms it's
  ignored.
- MAJOR (hermeticity): the new CLI flags are an invocation-shape change
  per JS_TEST_FORMAT_VERSION's own doc, which requires a bump - it hadn't
  been. Bumped to 2.
- MINOR (code-quality): a genuine stream-read error was silently folded
  into plain EOF, losing the earlier "wait for test runner: <io error>"
  diagnostic. Now tracked separately and surfaced when no JSON result
  exists to fall back on.
- MINOR (hermeticity/code-quality): the result-file read now runs through
  hcore::blocking::run (the codebase's standard bounded-blocking-pool
  helper) rather than inline std::fs::read on a tokio worker.
- Nits: dropped an unused Default derive, an unnecessary Arc::clone, and a
  failure-message check that string-matched test_failure_detail's internal
  "no output" sentinel instead of checking the actual stdout/stderr inputs.

Disclosed, not blocking: no test covers a genuinely torn/partial result-file
write (the polling code's tolerance for it is asserted by comment, not by
a test); no test drives real ctoken cancellation racing JSON detection;
the exact vitest/jest CLI flag syntax is reasoned from documentation, not
verified against a real toolchain (none exists in this devenv - the
existing #[ignore]d real-vitest tests predate this change and don't check
for the result file at all). DeadlineCancellable/EitherCancellable are two
small near-duplicate combinators rather than one generalized one - left
as-is rather than refactoring already-tested code under time pressure.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…g it

CI caught this (linux/amd64, linux/arm64) and the user hit it live against
a real vitest run within minutes of the previous fix publishing: the
"detects completion, exits promptly" mechanism itself could still hang
indefinitely.

Root cause: this driver always spawns its runner with setsid: false
(matching a real vitest/jest process, never made a session leader), so
killpg can never reach a descendant the runner backgrounds that inherits
its stdout/stderr — which is exactly what vitest's own dependency-optimizer
esbuild service does, the original bug's own trigger. proc_exec::output()
has always handled this: DRAIN_DEADLINE (500ms) bounds the post-exit drain
and abandons it once the reaped child's own output is in hand, precisely
because a stray descendant's copy of the pipe can outlive the child
indefinitely. The previous commit's rewrite moved off output() onto a
hand-rolled, unbounded drain loop and lost that bound entirely - so once
the direct child was killed (or exited), a lingering descendant still
holding the write end open left exec_runner waiting on an EOF that would
never come. Same symptom as the original bug, reached through the new
mechanism instead of the old one.

Fix: race the drain against the child being reaped (mirroring
proc_exec::output()'s own two-phase structure exactly - drain reaching EOF
doesn't end the wait, the wait ending doesn't yet end the drain, whichever
lands first the other keeps going), then bound any remaining drain by
DRAIN_DEADLINE and abandon it, logging a warning, using whatever was
captured. New regression test backgrounds a long-lived descendant that
inherits the pipe and exits the direct child normally - reproduces the
exact shape (confirmed by first running it against the prior commit's
drain loop, where it hangs past its own 15s bound).

Verified for real, not just compiled: ran the full plugin-js driver_test
suite natively on both darwin/arm64 (this machine) and linux/arm64 (a
fresh, uncached Docker container - not the devenv CI image, but real
Linux, real kernel pipes, no emulation) to directly address why the
previous commit passed everywhere I could check yet still failed on both
Linux CI runners and in the user's real environment. All 23 driver_test
tests pass on both. (One unrelated, pre-existing toolchain.rs test fails
in the bare Docker image over a missing tool the devenv shell provides -
unrelated to this diff, not investigated further here.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
Root cause of a live, real-world report: `@/gqlv3` (a tsconfig `paths`
alias) failed to resolve in vitest's dependency-optimizer scan even though
the aliased file was physically present in the sandbox and correctly
staged.

Diagnosed by tracing the whole js_src/js_test mechanism end to end
(query construction -> deps["codegen"] wiring -> query-addr Input
expansion -> sandbox staging), confirmed via `heph inspect deps`/`heph
inspect outputs`/direct --shell sandbox inspection with the user, live: the
codegen output WAS staged correctly, and js_test's own import-graph
resolver DOES use the package's tsconfig to resolve `paths`-aliased
specifiers into concrete first-party file Inputs. But `test_deps_config`
never declared the tsconfig FILE ITSELF as an Input -- only js_typecheck
and js_bundle did. Per architecture.md's isolation model ("a target sees
only its declared inputs"), that meant tsconfig.json was never staged into
js_test's sandbox at all. heph's own resolution worked fine on the host;
Vite's own alias resolution (a `tsconfig-paths`-style plugin reading
tsconfig.json directly, at runtime, inside the sandbox) had nothing to
read. This was an already-disclosed "known scope trim" in the module
docs, not a regression from this session's other work -- but it's the
thing actually blocking the user right now, and js_typecheck/js_bundle
already have the correct mechanism to mirror.

Fix: `test_deps_config` now finds the resolved tsconfig (the package's own,
or the nearest scoped ancestor's) and its whole `extends` chain, declares
each as an Input under a new `"tsconfig"` dep group (mirroring
`typecheck_deps_config` exactly), and threads `tsconfig_path`/
`tsconfig_content` through to `JsTestDef`, hashed directly (same
deliberate redundancy with the declared Input that `runner_config_content`
already has, and that `js_typecheck` has for its own `tsconfig_content`).
`JS_TEST_FORMAT_VERSION` bumped to 3 -- a cached result from before this
change never had the tsconfig staged, so it must not stand in for having
run with it.

New tests (mirroring the existing `typecheck_deps_config`/
`bundle_deps_config` tsconfig tests exactly): tsconfig group present +
content captured, absent when no tsconfig exists, and the `extends` chain
declared + both files' content hashed. Plus a `JsTestDef` hash-sensitivity
test for `tsconfig_content`, mirroring the existing `runner_config_content`
one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…an hang without a timeout

Two independent gaps in js_test's completion handling, both reported live
against the real monorepo:

1. exec_runner only ever reported captured stdout/stderr once it returned,
   so a stuck run showed nothing at all until it finished — forcing
   `heph run --shell` to reproduce by hand what had already printed. It now
   tees every chunk, as it arrives, to the engine's live terminal sink (when
   one is wired — unconditional on --shell, see run()'s doc comment) and to
   a log.txt in the sandbox, mirroring the pluginexec/docker_build.rs
   convention.

2. A failed Vite dependency-optimizer scan leaves an internal promise
   permanently unresolved, so vitest never writes the result file and never
   exits — a confirmed, unfixed upstream bug (vitejs/vite#22934,
   vitest-dev/vitest#9799), not a slow-but-working case. exec_runner now
   recognizes Vite's own diagnostic line the instant it's printed and kills
   the runner immediately instead of waiting out the 20-minute
   RUNNER_TIMEOUT backstop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…not an instant kill

A hermeticity review of the previous commit flagged a real correctness gap:
DEPENDENCY_SCAN_HANG_MARKER was an unanchored substring match that killed
the runner the instant it appeared on either stream, with no chance for a
legitimately-finishing run (one that happens to print the same words for an
unrelated reason) to still succeed.

Fix: anchor the marker to Vite's actual `(!) `-prefixed diagnostic, and
route detection through the existing poll task instead of cancelling
inline. The marker only records a timestamp; the poll task's existing
per-tick result-file check keeps running (and still wins first, every
tick), and only kills the runner once DEPENDENCY_SCAN_HANG_GRACE (5s)
elapses with still no result file. A real result file discovered within
that window wins the race regardless of whether the marker ever appeared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…m + vitest

crates/pluginjs-e2e mirrors crates/plugingo-e2e's shape for the js plugin:
a real Engine, in-process, against a real npm-installed workspace on disk,
running the real vitest binary — not the fake-shell-script harness the
existing unit tests use. Covers the passing/failing paths, that a cache hit
is a real LocalCacheHit (not just inferred from EResult, since js_test
declares no output artifacts), and that editing an imported (not just the
test) file invalidates the cache.

Adds pkgs.nodejs_24 to devenv.nix: js_test/js_typecheck/js_bundle/js_lint
all resolve their tool from <workspace_root>/node_modules/.bin/<tool>, a
disclosed non-hermetic escape hatch, so a real npm install needs npm on
PATH the same way the go toolchain tests already need go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…ith what a real subprocess resolves

Found live, via the new pluginjs-e2e suite: a real vitest run reported
"No test files found" against a test file that plainly existed in the
sandbox. Root cause has nothing to do with js specifically — on macOS,
/tmp and /var are symlinks to /private/tmp and /private/var (the default
TMPDIR a test harness's tempfile::tempdir() resolves into). Every absolute
path a managed driver builds off the workspace root (sandbox_dir and
everything derived from it, e.g. the absolute test-file path js_test
passes vitest as a CLI argument) inherited that uncanonicalized prefix,
but a real subprocess doing its own filesystem walk resolves the same
file to its canonical path. The two strings never matched.

Fixed at the source: crates/config's get_cwd() (HEPH_CWD or the process
cwd) now canonicalizes before returning, so bootstrap.rs's get_root() ->
ConfigYamlExt::resolve() -> Engine::new() chain is canonical end to end
for the real CLI binary. Engine::new also writes the canonicalized root
back into cfg.root itself (not just a local used to derive home), so
root()/PluginInit.root/RunRequest.tree_root_path — the field crossing the
plugin ABI to every driver, including out-of-process cdylib plugins — get
it too, not only sandbox-derived paths.

A first version of this fix only canonicalized inside Engine::new, which
a hermeticity review caught as dead code for the real binary (production
always pre-populates Config.home_dir from the pre-canonicalization root,
bypassing the branch that used the fixed value) and incomplete even where
it did apply (cfg.root itself stayed raw). Both are covered by dedicated
regression tests, verified red without the fix and green with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
The only place in the whole codebase shelling out to plain "sh" —
everywhere else (pluginexec's own bash driver, every other shell-out)
uses bash. Adds a test that actually discriminates the two: asserts on
$0 rather than a bash-specific language feature, since macOS's /bin/sh
is literally the same bash 3.2 binary as /bin/bash and would pass a
feature-based check either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…sions

Missed when the crate was added: like plugingo-e2e, pluginjs-e2e depends
on the root heph package with default features on (for htestkit), so it
needs the same --exclude on the fuse-sandbox feature-off clippy pass and
its cargo tree -i fuser probe, plus a place in qualityCrates for the fmt
check. tests/lint_gate.rs caught both gaps in CI (Lint and, since that
test lives in the root package, every Test job too).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…l root

Engine::new now unconditionally canonicalizes cfg.root, so skip_dirs()
comes back canonical even though this test's own helper hands it a raw
tempdir path. The assertion compared against that same raw path, which
only matches on a host where TMPDIR isn't itself a symlink — caught by
darwin CI, where /tmp/var is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…error twice

`failure_detail_from_json` always appended the raw stdout/stderr tail as a
"supplement" to the structured `testResults[].message`, on the reasoning
that some diagnostics (a warning printed outside any test result) only ever
live in the raw stream. But when the structured message is already a
substring of that raw tail — the common case, since vitest's own
`Error:`-prefixed terminal dump and the JSON reporter's `message` field
both derive from the same underlying error — appending it anyway just
prints the identical failure twice: once terse, once in full under
`--- raw stdout/stderr ---`.

Now skips the supplement when the raw tail already contains the structured
message verbatim, and keeps it otherwise (the genuine "message only lives
in the raw stream" case is unaffected). Two new tests cover both branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…prefix

Root cause of a live, real-world report: `setupFiles:
["src/tests/browser.setup.tsx"]` (no leading `./`, completely idiomatic —
both jest and vitest resolve `setupFiles`-family entries relative to root
regardless of a prefix) silently resolved to nothing in
`resolve_config_value_path`, which only handled `<rootDir>/...`, `./...`,
and `../...`. The setup file was therefore never declared as an Input,
never staged into the sandbox, and vitest's own later fetch of it failed
with `Failed to fetch dynamically imported module` and no resolution error
anywhere — a missing static asset, not an unresolved import specifier, so
none of the usual diagnostics fired.

Diagnosed by ruling out every other candidate first (upstream Vite/vitest
dependency-optimizer races, cache warmth, CLI invocation shape, staging
order) via faithful fixture reproduction on a real Vitest browser-mode
setup — none reproduced the actual symptom. `DEBUG=vite:deps` on the real
failing run showed the dependency-optimizer crawl entries list containing
only the test file, never the setupFiles entry; grepping the resolver
found the missing prefix-only branch.

Fix: fall through to the same `probe_first_party_path` check the other
branches use, with no prefix required. Safe against misclassifying a bare
npm package name (`setupFilesAfterEnv: ["jest-extended"]`) as a file,
since `probe_first_party_path` only returns `Some` when a file actually
exists on disk relative to the config's own directory.

Also fixes an adjacent instance of the same silent-drop pattern found
during the same review: a `setupFiles`-family value that resolves to
neither `<rootDir>` nor an on-disk file is a bare npm package name in
practice, but was previously just dropped — never staged, never declared.
Now collected as a bare specifier the same way the config's own
`import`/`require` statements already are, which routes it through the
existing phantom-dependency check (fails loudly if undeclared) and
dependency resolution (stages it if declared), rather than leaving it
silently missing from the sandbox.

New tests cover both the bare-relative-path resolution and the
bare-package-name collection.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…renced files

Root cause of the deeper half of the same live report: `browser.setup.tsx`
(now correctly staged) itself does `import { loadMessages } from
"@/locale"` — a tsconfig `paths` alias (`@/* -> ./src/*`) pointing at a
*directory* (`src/locale/index.ts`). `resolve_runner_config_referenced_files`'s
import scan only ever tried `resolve_config_import_specifier` (relative
`./`/`../` only) and `bare_specifier_package_name` (a valid npm scope
shape) for each import site inside a referenced file. `@/locale` is
neither — `bare_specifier_package_name("@/locale")` is `None`, same as the
already-tested `"@/components/Foo"` case — so it fell through both
branches and was silently dropped: `src/locale/index.ts` (and its own
`catalogs/`) were never declared as Inputs, never staged, and Vite's own
runtime resolution of `@/locale` failed with "does the file exist?" and no
indication why, even though the file was right there on the real host
tree (confirmed live: `ls` inside the actual sandbox showed `compiled/`
present — a codegen target's automatic output — but `index.ts`/`catalogs/`
missing).

Fix: thread the package's resolved tsconfig (`test_deps_config` already
has it via `find_nearest_tsconfig`) through to
`resolve_runner_config_referenced_files`, and try the same
`Resolvers`/`oxc_resolver` machinery `build_test_closure` already uses for
the test file's own graph. Only a result landing *outside* `node_modules`
counts as a newly-discovered first-party file — a specifier that resolves
into an installed package is left entirely to the existing
`bare_specifier_package_name`/lockfile-driven path, never decided by
walking `oxc_resolver` against ambient `node_modules` (the M3-review
lesson this crate already learned once).

`resolve_eslint_config_referenced_files`/the bundler-config caller pass
`None`: an eslint flat config or esbuild JSON config importing app code via
a tsconfig alias is out of scope for this fix (already-disclosed gaps in
those callers' own doc comments).

New test reproduces the exact shape: a setupFiles-referenced file
importing via a tsconfig `paths` alias to a directory index.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…ynamic import()

`import(`./catalogs/${locale}.po`)` is Vite's own "dynamic import with a
variable" pattern — it globs the whole prefix directory at dev-server time
and needs every matching file present, not just the one a given test run
picks. The import-graph scanner only ever handled string-literal `import()`
arguments; anything else (including this shape) was coarsened into an
unresolved-dynamic-imports counter with no Input declared, so the directory
was silently absent from the sandbox.

Recognize the single-interpolation, literal-prefix/literal-suffix shape in
the AST, thread it through `ImportGraph`/`TestClosure` as a
`ResolvedGlobSite`, and declare it as one `fs:glob` Input in
`test_deps_config` — reusing the same mechanism plugin-go already uses for
static-embed subtrees. Shapes no bundler could resolve either (multiple
interpolations, a nested suffix path, a bare prefix) still coarsen as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…les too

The previous fix (f83c284) only wired the new GlobImportSite handling into
the test file's own import closure (build_package_import_graph/
build_test_closure). It missed the other place a dynamic import() gets
discovered: resolve_runner_config_referenced_files, the separate BFS that
walks setupFiles/setupFilesAfterEnv-referenced files. That's exactly the
path the real report goes through — a browser-mode setup file named by
setupFiles imports a tsconfig-aliased module, which itself dynamically
imports a locale catalog directory — so the directory was still silently
missing from the sandbox even after the first fix landed.

Thread GlobImportSite through RunnerConfigScan the same way, using the same
join_glob_dir core (now shared between both pipelines), and declare it as a
glob Input in test_deps_config alongside the test file's own glob_sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
js_test and js_lint carried no labels at all, so label(test)/label(lint)
queries (and anything scripted on top of them, e.g. CI) couldn't select
every JS test/lint target the way they already can for Go. Mirror
plugin-go's own generic + tool-family-specific pairing: test/go-test in
target_test.rs, lint/go-lint in driver_lint.rs.

js_lint has no separate fix/check variant (no --fix support today), so it
gets the single lint/js-lint pair rather than go's lint vs lint-check split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
… read-only

Every js_test Input was byte-copied into the sandbox on every run, including
node_modules and any workspace-sibling code pulled in via "external" — the
same cost plugin-go already avoids for its Go SDK by marking that dep group
read-only, which routes staging through a symlink instead of a full copy.

Mirror that here for the three groups nothing ever writes into at test time:
"external" (resolved third-party/workspace-sibling code), "runner_config",
and "tsconfig" (config files, only ever read). The unnamed "" group (the
test file's own first-party closure) stays writable — Vitest's
toMatchSnapshot() writes .snap files next to the test file, so read-only
staging there would turn a first-time snapshot write into an EACCES crash.
"codegen" stays writable too: a js_src target's generated files land at
real source-tree paths inside the package, the same co-located-with-
hand-written-source shape that makes "" unsafe, and its own Content shape
isn't uniform enough across every possible js_src-labelled driver to assume
it's safe the way the other three groups are.

annotations never reach the input hash or JsTestDef::hash, so this is
cache-key-stable — no existing js_test result goes stale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
… directory symlink

The first attempt (b38a5b8, reverted in 4b5f065) marked external/
runner_config/tsconfig read-only via a plain directory symlink
(READ_ONLY_ANNOTATION alone) and broke real js_test runs: Node's require()
calls fs.realpath() on the required file before walking up for
node_modules, and a symlinked ancestor directory resolves to the stage's
own isolated path — which has no matching node_modules chain (or any
sibling) next to it. vitest.config.ts's own plugin imports, all genuinely
present in the sandbox's node_modules, failed to resolve for exactly this
reason.

Add STAGE_PER_FILE_ANNOTATION alongside READ_ONLY_ANNOTATION: link_tree
hardlinks every file directly into its real sandbox path instead of
symlinking a directory, so no ancestor in the destination tree is ever a
symlink and realpath() returns the sandbox path itself. Slightly more
syscalls than a directory symlink (O(files) vs O(depth)), still far
cheaper than the byte-copy this exists to skip.

Added crates/driver-support/src/stage.rs's
per_file_single_file_input_has_no_symlinked_ancestor as the regression
test this bug needed and didn't have: it walks every ancestor of a
per-file-staged leaf and asserts none is a symlink, and that the leaf's
own realpath lands inside the sandbox rather than in stage/ — the exact
property Node's resolver depends on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
Mirrors plugin-go's `test = False` / `recursive = True` mechanism:
`provider_state(provider="js", enabled=False)` disables every js target
kind for a package (package_info/js_typecheck/js_lint/js_test/js_bundle/
node_modules sync); `test=False`/`lint=False` disable one target kind at
a time; `recursive=True` extends whichever key it's set alongside to
descendant packages.

Extracts the shared closest-package-wins precedence algorithm
(state_is_recursive/state_applies_to/applicable_states) out of plugin-go
into hplugin::provider so both plugins share one implementation instead
of two copies of the same logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…ust errors

Real bug, found via a new pluginjs-e2e test that drives a real oxlint
binary end to end: both oxlint and eslint exit 0 on warning-severity
findings by default, only failing the process on an error-severity
rule. js_lint only checked the process exit code, so a real, reported
violation (e.g. oxlint's own no-debugger, which defaults to "warn")
silently passed the target — the lint gate was effectively a no-op for
most default rule configurations.

Fix: pass --deny-warnings (oxlint) / --max-warnings 0 (eslint) so any
finding, warning or error, fails the target.

Adds js_lint e2e coverage in crates/pluginjs-e2e (real npm install +
real oxlint, mirroring the existing js_test e2e shape) — previously
js_lint had none. The driver's own #[ignore]d "real oxlint" unit tests
never actually exercised this path (their harness always passes empty
inputs, so run() takes the early no-op return before invoking any
linter) — noted as a separate, pre-existing gap, not fixed here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…swallows every source

Reproduced the reported "No files found to lint. Please check your
paths and ignore patterns." exactly against a real oxlint (1.78.0):
js_lint always passes every first-party source file as an *explicit*
path argument, and oxlint still applies the resolved config's own
ignorePatterns to explicitly-named files — if every declared source
happens to match, oxlint reports zero files and (on this version)
exits nonzero, even though heph genuinely staged real files. --no-ignore
does not override config-level ignorePatterns (verified), so there is
no cheap CLI-flag fix that preserves heph's declared-Input set.

This is a linter-config/heph-Input mismatch, not a staging bug — the
files are real and correctly staged. Until ignorePatterns-aware
filtering exists on the heph side (a larger, separate change), make
the failure diagnosable instead of confusing: report how many source
files were declared and which config resolved, and when oxlint's own
"No files found to lint" wording is detected, point at the two real
remedies (narrow the ignore rule, or opt the package out via the
provider_state(lint = False) knob added earlier this session).

New e2e (crates/pluginjs-e2e) reproduces the exact scenario with a
real oxlint 1.78.0 under a real ignorePatterns-matches-everything
config and asserts both the failure and the new actionable message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
…g to oxlint

Reported live bug, second half: "i'm not using oxlint, i'm using
eslint" — but the js provider's `linter` option had no auto-detection
and defaulted to `"oxlint"` unconditionally. In a workspace with only
eslint installed, that default made heph resolve a stray PATH `oxlint`
binary and run it, producing an oxlint-specific failure the workspace
owner had no way to explain (they never chose oxlint).

`linter` now has no static default. Unset, it auto-detects from what's
actually installed at `<workspace_root>/node_modules/.bin/` — never
`PATH`, since a stray global binary unrelated to the project is
exactly the wrong signal to decide this on. Detection fails loudly
(not a guess) when neither `oxlint` nor `eslint` is installed, or when
both are and the choice is genuinely ambiguous — mirroring the
`pkgmanager` option's existing "no default, an ambiguous silently-
picked answer is worse than requiring an explicit choice" precedent in
this same provider. An explicitly configured `linter` is unaffected.

`crates/pluginjs-e2e`'s shared workspace config now passes `linter:
None`, so every js_lint e2e test exercises real detection rather than
an assumed default; added two more fixtures/tests proving detection
picks eslint correctly and that the `--max-warnings 0` fix from the
prior commit actually works against a real eslint (previously
untested against real eslint end to end).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
@raphaelvigee
raphaelvigee force-pushed the raphaelvigee/feat-plugin-js-m8-bin-e2e branch from c3a68c8 to b0fc51d Compare August 17, 2026 08:53
raphaelvigee and others added 2 commits August 17, 2026 11:36
…onfig file

Two changes, both from live user feedback on the auto-detection commit:

1. Detection now checks for each tool's own config file at the
   workspace root (.oxlintrc.json; eslint.config.*/.eslintrc.*) instead
   of binary presence in node_modules/.bin. A config file is the
   workspace owner's actual declared choice; binary presence isn't — a
   repo mid-migration between tools, or one that carries both as
   transitive/dev tooling for unrelated reasons, can have both binaries
   installed with only one actually configured, which made the
   binary-presence version spuriously call that "ambiguous".
   lint_config_candidates (the per-tool config filename list) moved
   from provider.rs into toolchain.rs so detect_linter can reuse it
   instead of duplicating the knowledge.

2. `linter` is no longer a provider-construction-level `Config`/YAML
   option at all — it's set (or left to auto-detect) per package via
   `provider_state(provider = "js", linter = "oxlint" | "eslint")`,
   the same closest-package-wins + `recursive` shape `enabled`/`test`/
   `lint` already have. `resolved_host_linter` now resolves per-package
   states and caches the binary/version by *resolved linter name*
   (a keyed HashMap<String, OnceCell<...>> instead of a single OnceCell)
   since different packages can now resolve to different linters in
   the same run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
… root alone

js_lint is a per-package target, but toolchain::detect_linter only ever
checked the bare workspace root for a linter config — a package with
its own config and no root-level config at all (a very normal
monorepo shape) got a false "could not detect a linter" even though
the linter config was right there.

Fixed to reuse the exact same ancestor-chain walk
Provider::lint_config already resolves the config's own *content*
against (importgraph::find_nearest_lint_config, pkg_dir up to
workspace_root, both inclusive) instead of checking workspace_root in
isolation. When both tools have a config somewhere on that chain, the
closer one wins — mirroring every other closest-declaration-wins
resolution this plugin already has (provider_state's own ancestor
precedence, find_nearest_lint_config itself); only a genuine tie (both
configs in the identical directory) is treated as ambiguous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLhaQSMoXo1ExzAeCbu3an
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.

1 participant