Add resolver module for package manager and task runner selection#22
Conversation
Phase 1 of the unified resolution chain. Creates `src/resolver/` with a Resolver that wraps ProjectContext and exposes resolve_node_pm() returning a ResolvedPm tagged with the step that produced it. No behavior change: the decision is the historical primary_node_pm > primary_pm > Npm fallback that lived inline in cmd::run::build_run_command. Later phases extend ResolutionStep with the new override/manifest/probe sources.
…ER env Phase 2 of the unified resolution chain. The resolver now honors a CLI override (`--pm`, `--runner`) and its env-var mirror (`RUNNER_PM`, `RUNNER_RUNNER`), with CLI winning over env. Both flags are global so they apply to every subcommand and to the `run` alias binary. `ResolutionOverrides::from_cli_and_env` collects sources and returns a typed bundle tagged with `OverrideOrigin` for later diagnostics. The parse step is split into `from_values` so tests can inject env values without touching the process environment. Override semantics for `resolve_node_pm`: - A PM override that can dispatch `package.json` scripts (Node ecosystem plus Deno) wins immediately and is recorded as `Override(origin)`. - A cross-ecosystem override like `--pm cargo` falls through to detection rather than hijacking Node script dispatch; Phase 5 will surface this as a warning. - The legacy `npm` fallback is preserved so behavior is unchanged when no override and no detection signal are present. `PackageManager::from_label` / `TaskRunner::from_label` parse the user- supplied values; `all()` enumerates the valid values for error messages. The `bundle` alias for Bundler and `go-task` alias for the GoTask runner are accepted so users can spell the CLI name they know. The `--runner` value is parsed and stored for now; source-selection semantics land with Phase 8.
Phase 3 of the unified resolution chain. A `runner.toml` at the project
root now contributes per-ecosystem PM overrides that win over detection
but lose to CLI/env.
`src/config.rs` (new) loads `runner.toml`, deserializes the full schema
(`[pm]`, `[task_runner]`, `[resolution]`, `[sources.*]`) and rejects
unknown keys via `serde(deny_unknown_fields)` so typos surface at parse
time. Only `[pm]` is consumed today — the rest is captured for later
phases.
Resolver changes:
- `OverrideOrigin` gains `ConfigFile { path }` and is now `Clone`
instead of `Copy` (PathBuf can't be Copy). PmOverride/RunnerOverride/
ResolvedPm follow.
- `ResolutionOverrides.pm_by_ecosystem: HashMap<Ecosystem, PmOverride>`
carries the per-ecosystem map sourced from `[pm].node`/`[pm].python`.
- `Resolver::resolve_node_pm` consults the map (Node entry, then Deno
entry) after CLI/env falls through.
- `from_cli_and_env` / `from_values` take an `Option<&LoadedConfig>`
argument and surface ecosystem-mismatch errors (`[pm].node = "cargo"`
fails fast).
`types::PackageManager::ecosystem()` plus the new `Ecosystem` enum
formalize the Node/Deno/Python/Rust/Go/Ruby/PHP mapping the resolver
needs to keep CLI overrides scoped to compatible ecosystems.
`lib::dispatch` and `dispatch_run_alias` load the config once and pass
it through `from_cli_and_env`.
Phase 4 of the unified resolution chain. The resolver now consults
package.json's `packageManager` field and the new
`devEngines.packageManager` field (OpenJS proposal, npm 10.9+) between
the CLI/env/config overrides and the lockfile fallback.
`tool::node`:
- `parse_package_manager_spec` extracts the `name@version` form once and
is shared between the legacy field and devEngines entries.
- `DevEngines` / `DevEnginesPmField` (untagged enum) accept either a
single object or an array per the spec.
- `OnFail` carries the four proposal values mapped to runner's three
effective modes (`download` collapses to `Warn` — runner is not an
installer). Defaults follow the proposal: single object → `Error`,
last array entry → `Error`, prior entries → `Ignore`.
- `detect_pm_from_manifest` returns `Option<ManifestPmDecl>` favoring
the legacy field, falling through to `devEngines`, and picking the
last resolvable array entry.
Resolver:
- New `ResolutionStep::ManifestPackageManager` and
`ManifestDevEngines { on_fail }` variants.
- `resolve_node_pm` consults `detect_pm_from_manifest` between
config-sourced overrides and the lockfile fallback; manifest wins over
lockfile per Corepack semantics.
- `cross_check_against_lockfile` emits a `package.json` warning when
the declared PM disagrees with the detected lockfile signal,
attributing the discrepancy and telling the user to regenerate the
lockfile.
- `ResolvedPm` gains a `warnings` field so resolver-time warnings
travel back to the caller.
`cmd::run::build_run_command` prints resolver warnings before spawning
the underlying command. `cmd::print_warning_slice` is the shared
formatter used by both `print_warnings(&ctx)` and the resolver path.
Phase 5 of the unified resolution chain. When no override, manifest, or lockfile signal selects a package manager, the resolver now walks `$PATH` in canonical order (`bun > pnpm > yarn > npm`) and picks the first installed binary instead of silently defaulting to `npm`. If nothing is on `$PATH`, the user sees an actionable error that lists every source that was checked and how to pin one. New `--fallback <policy>` global flag (and `RUNNER_FALLBACK` env mirror, `[resolution].fallback` in `runner.toml`) gates the behavior: - `probe` (default) — PATH probe, error if nothing matches. - `npm` — legacy silent fallback (opt-in for backwards compatibility). - `error` — refuse to proceed without an explicit signal. `devEngines.packageManager` onFail handling now plugs into the same probe primitive: - `ignore` — use the declared PM, no check. - `warn` — emit a warning when the declared PM is missing from PATH but still attempt dispatch. - `error` — bail immediately when the declared PM is missing. `src/resolver/probe.rs` (new) is the pure PATH walk — `probe_in` takes PATH and PATHEXT explicitly so unit tests can exercise it against a temp directory. Windows shim resolution mirrors the approach used by `tool::program::command`. `Resolver::resolve_node_pm` now returns `Result<ResolvedPm>` so the error-path policies and `onFail = error` declarations have somewhere to land. All call sites updated.
…ripts Phase 7 generalizes passthrough-wrapper detection so a package.json script like \"build\": \"just build\" is recognized as a thin wrapper around the underlying task runner — not just turbo. New `src/tool/passthrough.rs` hosts a conservative one-shot matcher (`detect_target`) that returns the `TaskRunner` a script wraps: - Turbo (delegates to the existing `tool::turbo::is_self_passthrough` which carries its detailed shell-token validation). - just / make / go-task (`task`) / bacon (positional task name). - nx / mise (`run <task>` shape). Any shell-active tail (control operators, redirects, `$` expansion, backtick substitution) keeps a script visible so wrappers that actually do work — `just test && echo done`, `task lint > out.log` — are not silently collapsed. `Task.passthrough_to_turbo: bool` becomes `Task.passthrough_to: Option<TaskRunner>`. The completion dedup logic in `cli::task_candidates_from` checks the new field against `TaskRunner::task_source()`: a `package.json` script is hidden only when its declared target runner also has a same-named task entry to absorb it (peer-source check), so wrappers to runners without task extraction (nx, mise) stay visible.
Phase 8 collapses the two task-selection paths in cmd::run — the non-Deno source_priority path and the Deno-only (depth, display_order) path — into one. The unified sort key is: (source_priority, source_depth, display_order, alias_of.is_some()) source_priority keeps the Turbo > PackageJson > others ordering it has today, and source_depth is now consulted for every selection: among ties on source_priority, the source whose config sits in the nearest ancestor of the current directory wins. Deno's existing deno_task_priority is removed in favor of the shared source_depth helper. Behavior at parity for most projects (configs at root collapse depth to a no-op tiebreak; display_order takes over). The win is in nested Cargo/Make/Just layouts where additional `walk_upwards` source implementations can later plug into source_depth and immediately benefit from the nearest-config tiebreak the Deno path proved out.
Phase 6 (minimal scope). Adds a global `--explain` flag and matching
`RUNNER_EXPLAIN` env var that emit a single-line attribution to stderr
after PM resolution:
· runner resolved: yarn via package.json "packageManager"
The `doctor` and `why` subcommands described in the spec are deferred —
this slice ships the highest-value piece (attribution at run time) and
leaves the standalone diagnostic tools for a later change.
`ResolvedPm::describe` renders each `ResolutionStep` variant into a
human-readable label:
- Override(CliFlag) / Override(EnvVar) / Override(ConfigFile { path })
- ManifestPackageManager / ManifestDevEngines { on_fail }
- Lockfile / PathProbe { binary } / LegacyNpmFallback
`ResolutionOverrides` gains an `explain` flag plus a
`from_values_with_explain` constructor; `from_cli_and_env` takes the
new `cli_explain` bool. The CLI value (`Some(true)` when --explain is
passed) wins over env (non-empty truthy values: anything except `0`,
`false`, `no`, `off`).
The plain `from_values` helper is now `#[cfg(test)]` since production
goes through `from_values_with_explain`; tests keep using the shorter
form.
`runner doctor` dumps everything the resolver considers in the current
directory — detected PMs/runners, override sources in effect (CLI/env/
runner.toml with origin attribution), manifest declarations
(packageManager + devEngines fields with onFail), PATH probe results for
each Node PM, the final node-script PM decision, and any warnings.
Pairs with --explain (one-line at run time) and runner why (per-task).
`runner why <task>` walks the source-selection chain for a single task
name: lists every candidate source with its (priority, depth,
display_order, alias) tuple, names the winner, and — when the picked
source is `package.json` — renders the PM resolution trace plus
mismatch warnings.
Both subcommands ship two output modes:
- human: colored, section-grouped, easy to skim.
- --json: schema-versioned (schema_version=1) JSON suitable for jq,
scripts, and bug-report templates. Both renderers consume the same
serde_json::Value so the human view never drifts from the JSON schema.
Implementation:
- src/cmd/doctor.rs and src/cmd/why.rs — new subcommands.
- cmd::run::{select_task_entry, source_priority, source_depth} are now
pub(crate) so cmd::why can reuse the live selection logic instead of
duplicating it.
- resolver::probe_path_for_doctor re-exports probe::probe_in so the
doctor probe exercises the same PATH walk the resolver uses.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
📜 Recent review details⏰ Context from checks skipped due to timeout of 18000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (4)
🧰 Additional context used🧠 Learnings (3)📚 Learning: 2026-03-26T20:05:44.851ZApplied to files:
📚 Learning: 2026-04-21T15:16:40.277ZApplied to files:
📚 Learning: 2026-05-04T23:28:17.947ZApplied to files:
🔇 Additional comments (5)
📝 WalkthroughIntroduce resolver module and diagnostics for deterministic package-manager and task-runner selection This PR adds a resolver system and supporting UX/diagnostics to deterministically choose which package manager and task runner executes package.json scripts, surface provenance for decisions, and provide machine- and human-readable diagnostics. Core changes
API / type changes
Notable implementation notes
Reviewer findings / required fixes before merge
Changelog / packaging
Merge criteria
WalkthroughAdds strict project ChangesConfiguration-Driven Package Manager Resolution
Sequence Diagram(s)sequenceDiagram
participant CLI
participant Dispatch
participant Resolver
participant ProjectContext
participant PATHProbe
participant Tool
CLI->>Dispatch: parse flags & load runner.toml -> build ResolutionOverrides
Dispatch->>Resolver: resolve_node_pm(overrides)
Resolver->>ProjectContext: inspect manifests & lockfiles
Resolver->>PATHProbe: probe_all(NODE_PROBE_ORDER)
Resolver->>Tool: run <pm> --version (onFail/version checks)
Resolver-->>Dispatch: ResolvedPm (pm, via, warnings) or ResolveError
Dispatch->>Tool: build_run_command + execute (emit explain/warnings if requested)
Possibly related PRs
✨ Finishing Touches📝 Generate docstrings
✨ Simplify code
|
Wires `semver::VersionReq` into the `apply_manifest_on_fail` codepath so
a `devEngines.packageManager` entry with a `version` range is now
validated against the installed PM's reported version, not just its
presence on PATH:
- `Ignore` — no check.
- `Warn` — emit a `package.json` warning when the version doesn't
satisfy the range; continue with the declared PM.
- `Error` — bail with the declared range + installed version when they
don't match.
`check_version_constraint(pm, range)` returns `VersionCheck::Satisfied
| Mismatch { declared, actual } | Unverifiable { reason }`. The
unverifiable branch collapses unparseable ranges and missing
`<pm> --version` output to a silent skip so a partially-broken
environment never blocks dispatch — the spawn-time error is clearer.
Two CodeRabbit-flagged hardenings on the manifest parser:
- `devEngines.packageManager` now drops entries naming a PM that
cannot dispatch `package.json` scripts (Cargo, uv, Poetry, etc.)
at parse time. Without this, `{"name": "cargo"}` would surface as
an apparently-valid decision and fail with the opaque "cannot run
scripts" error from `build_run_command`.
- The `onFail` array default tracks the picked entry's position among
*resolvable* peers, not its raw array index. Without this, a
trailing unresolvable name like `{"name": "zoot"}` would downgrade
the previously-resolvable entry's default from `Error` to `Ignore`.
Also corrects the stale `// turbo passthrough wrapper` doc on
`push_package_json_tasks` to reflect that it now classifies wrappers
for any known runner (turbo, just, make, task, nx, bacon, mise).
New deps: `semver = "1"`.
Both the no-task fallback paths in cmd::run were bypassing the resolver: `--pm npm` against a Bun-detected project would still trigger the bun-test special case, and `runner run eslint --pm yarn` would silently dispatch through whichever PM detection picked. Both now consult ResolutionOverrides first and fall back to detected context only when no override applies, matching the precedence used by the PackageJson dispatch path. - `run_pm_exec_fallback(ctx, overrides, target, args)` — pulls the script-dispatching PM out of the override bundle (CLI/env first, then per-ecosystem config) before falling back to `ctx.primary_pm`. - `run_bun_test_fallback(ctx, overrides, task, args)` — only fires when the effective PM (override or detected) is actually Bun. - `should_use_bun_test_fallback` checks the override chain so `--pm npm` correctly suppresses the fallback even when bun.lock is detected. - New helpers: `exec_pm_for_overrides` (extracts an override-chosen PM, preferring CLI/env over config) and `pm_dispatches_scripts` (small predicate matching the existing exec-cmd-supporting set). CLI doc comments for `--pm`, `--runner`, `--fallback`, and `--explain` no longer claim they "fall back to env vars" via clap — env reads live in the resolver, so the docs now name `crate::resolver` as the reader to avoid suggesting clap's `env` attribute is in play. Two new tests prove the precedence: - `--pm npm` against a Bun-detected ctx suppresses bun-test fallback. - `--pm bun` against an empty ctx still enables it.
`source_dir` previously only walked upward for `PackageJson` and `DenoJson` (via per-tool helpers that respect workspace boundaries). Every other source (`Makefile`, `Justfile`, `Taskfile`, `BaconToml`, `TurboJson`) used `find_first(root, …)` which only checked the literal project root, so in a nested layout the depth tiebreak always collapsed to `0` or `usize::MAX` — making it a no-op once `source_priority` tied. Now every non-workspace-aware source consults `tool::files::find_first_upwards`, which walks ancestors and stops at the VCS boundary (`.git`/`.jj`). A member project running `runner build` inside `apps/api/` resolves the workspace-root `Makefile` with a finite depth, and the resolver's `(source_priority, source_depth, display_order, alias_last)` key correctly picks the nearest config when ties happen across same-priority sources. `PackageJson`, `DenoJson`, and `CargoAliases` keep their bespoke walkers because each models workspace membership rules (member globs in `pnpm-workspace.yaml`, `deno.json`, and the `.cargo/config.toml` chain) that a plain ancestor walk doesn't capture.
Resolver work introduces new public surface (`runner doctor`, `runner why`, `--pm`/`--runner`/`--fallback`/`--explain` global flags, `runner.toml` config) plus a small but real API break — the resolver chain reorders detection-vs-declaration precedence and `Task` swaps its `passthrough_to_turbo: bool` field for `passthrough_to: Option<TaskRunner>`. Minor bump per SemVer; CodeRabbit's pre-merge checks flagged both the missing changelog entry and the missing version bump on the PR. Cargo.lock regenerated on next cargo invocation; no other artifacts change.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
runner | 41e15a7 | Commit Preview URL Branch Preview URL |
May 13 2026, 11:05 AM |
Three small fixes flagged on the follow-up commits: - CHANGELOG.md gains the required `## [0.9.0] - 2026-05-12` section header alongside `[Unreleased]`. The repo's pattern is to date entries with the day the version is committed (matches `[0.8.1] - 2026-05-12` in the same file); the actual tag-time date can be amended on release if it drifts. - `runner doctor` now folds resolver-side warnings into its output. Previously a `package.json` PmMismatch warning produced by the resolver was generated and dropped — the doctor surface is exactly where that diagnostic belongs. Both the JSON `warnings` array and the human `Warnings` section now show the combined `ctx.warnings` plus `decision.warnings` list. - `is_env_truthy` (used by `RUNNER_EXPLAIN` and any future env-driven bool flag) now compares case-insensitively after trimming whitespace. `RUNNER_EXPLAIN=FALSE` no longer silently turns explain on; same for `No`, `OFF`, and stray surrounding newlines from shell-export patterns. Tests cover both behavior changes (case-insensitive truthy plus the doctor-merges-warnings end-to-end path). Also removes a clippy redundant_clone nit on a test helper introduced in the previous commit.
This comment was marked as outdated.
This comment was marked as outdated.
Three correctness/polish fixes from the internal re-review of PR #22: 1. `installed_version` (devEngines version check) now spawns via `tool::program::command` instead of bare `Command::new`, so Windows `npm.cmd` / `pnpm.cmd` / `yarn.cmd` shims resolve via PATHEXT. This is the exact bug fixed for the dispatch path in 0.8.1 (#21); without the fix, `devEngines.version` enforcement would silently degrade to `VersionCheck::Unverifiable` on Windows. 2. Split the previous `pm_dispatches_scripts` helper into two narrower predicates so `--pm deno` no longer silently falls through the arbitrary-command exec path: - `pm_has_exec_primitive` — only PMs with `npx`-style exec (`npm`, `pnpm`, `yarn`, `bun`, `uv`). Used by `exec_pm_for_overrides` for the run-arbitrary-command fallback. Drops Deno explicitly because `deno run <target>` runs a local script file, not a node_modules binary — honoring `--pm deno` here would silently change semantics. - `pm_dispatches_node_scripts` — Node ecosystem plus Deno. Used by `node_script_pm_for_overrides` to answer "did the user pick Bun for this Node project?" in the bun-test fallback path. Behavior change: `runner --pm deno run somebin` now reaches the direct-PATH spawn arm without the Deno override leaking into the match, which both matches the doc comment's promise and surfaces the "Deno has no exec primitive" rationale in the call sites. 3. `parse_override` now trims whitespace from both CLI and env values before parsing, and treats whitespace-only values as unset. Matches the whitespace handling already used by `is_env_truthy` for boolean env flags. Without this, `RUNNER_PM=" pnpm "` (a common shell-export pattern with trailing newlines) errored on "unknown package manager" while `RUNNER_EXPLAIN=" true "` worked. Three new tests guard each change: - `exec_pm_for_overrides_excludes_deno` - `node_script_pm_for_overrides_includes_deno` - `parse_override_trims_whitespace_in_env_and_cli`
`apply_manifest_on_fail` now takes `is_present` and `check_version` closures so the `onFail = Error` branches are testable. Production calls in `Resolver::resolve_node_pm` wire in the real implementations (`real_binary_check` walks `$PATH` via `probe::probe`, `check_version_constraint` spawns `<pm> --version`); unit tests pass deterministic mocks that don't depend on the host system. Closes the test-coverage gap flagged in the internal review — neither `Error + missing binary` nor `Error + version mismatch` was previously exercisable without controlling the real PATH and binary versions. Six new tests cover the full matrix: - `manifest_on_fail_error_bails_when_binary_missing` - `manifest_on_fail_error_bails_when_version_mismatched` - `manifest_on_fail_warn_emits_warning_when_binary_missing` - `manifest_on_fail_warn_emits_warning_on_version_mismatch` - `manifest_on_fail_ignore_skips_all_checks` (uses panicking checkers to prove the early return) - `manifest_on_fail_unverifiable_version_continues_without_warning` Behavior unchanged for callers — the production path wires the same `probe::probe` + `check_version_constraint` calls that lived inline before, just through `FnOnce` parameters.
`ResolutionOverrides::from_values_with_explain` was 9 positional
`Option<&str>` parameters — adding any new override source (`--on-
mismatch` / `RUNNER_ON_MISMATCH` would be the next one) meant a 2-arg
expansion across every test site plus the production wrapper. The
`#[allow(clippy::too_many_arguments)]` was a sign of the underlying
shape problem, not a real local concern.
Introduces three new types in `src/resolver/mod.rs`:
- `OverrideSources<'a>` — bundles every CLI/env input the resolver
consumes (pm/runner/fallback/explain + config). `Default`-able so
tests construct partial bundles with `..OverrideSources::default()`.
- `SourceValue<'a>` — `{ cli: Option<&str>, env: Option<&str> }` for
string-typed overrides.
- `ExplainSource<'a>` — same shape but `cli: bool` for the
presence-flag pattern used by `--explain`.
`from_sources(OverrideSources)` is the new pure-function constructor.
`from_cli_and_env` becomes a thin wrapper that reads `std::env` and
builds the struct. `from_values_with_explain` is removed entirely;
`from_values` survives as a `#[cfg(test)]` adapter so the existing 14
test sites don't churn, but the doc points new tests at `from_sources`
directly:
ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue { cli: Some("yarn"), env: None },
..OverrideSources::default()
})
One new test (`from_sources_builder_is_ergonomic_for_partial_overrides`)
demonstrates the target shape so future migrations have a pattern to
follow.
Three correctness/polish fixes from the internal re-review of PR #22: 1. `installed_version` (devEngines version check) now spawns via `tool::program::command` instead of bare `Command::new`, so Windows `npm.cmd` / `pnpm.cmd` / `yarn.cmd` shims resolve via PATHEXT. This is the exact bug fixed for the dispatch path in 0.8.1 (#21); without the fix, `devEngines.version` enforcement would silently degrade to `VersionCheck::Unverifiable` on Windows. 2. Split the previous `pm_dispatches_scripts` helper into two narrower predicates so `--pm deno` no longer silently falls through the arbitrary-command exec path: - `pm_has_exec_primitive` — only PMs with `npx`-style exec (`npm`, `pnpm`, `yarn`, `bun`, `uv`). Used by `exec_pm_for_overrides` for the run-arbitrary-command fallback. Drops Deno explicitly because `deno run <target>` runs a local script file, not a node_modules binary — honoring `--pm deno` here would silently change semantics. - `pm_dispatches_node_scripts` — Node ecosystem plus Deno. Used by `node_script_pm_for_overrides` to answer "did the user pick Bun for this Node project?" in the bun-test fallback path. Behavior change: `runner --pm deno run somebin` now reaches the direct-PATH spawn arm without the Deno override leaking into the match, which both matches the doc comment's promise and surfaces the "Deno has no exec primitive" rationale in the call sites. 3. `parse_override` now trims whitespace from both CLI and env values before parsing, and treats whitespace-only values as unset. Matches the whitespace handling already used by `is_env_truthy` for boolean env flags. Without this, `RUNNER_PM=" pnpm "` (a common shell-export pattern with trailing newlines) errored on "unknown package manager" while `RUNNER_EXPLAIN=" true "` worked. Three new tests guard each change: - `exec_pm_for_overrides_excludes_deno` - `node_script_pm_for_overrides_includes_deno` - `parse_override_trims_whitespace_in_env_and_cli` https://claude.ai/code/session_01GWcKN1coQ4nALYrf5fPv7K
The "can dispatch package.json scripts" predicate (`pm.is_node() || matches!(pm, Deno)`) was duplicated byte-for-byte in two modules: - `resolver::pm_can_run_package_json_scripts` (line 452) - `cmd::run::pm_dispatches_node_scripts` (line 329) A future change to the rule (e.g. adding another script-dispatching runtime) would have to update both sites. Same shape for the exec-primitive check (`pm_has_exec_primitive` in `cmd::run`). Both now live as inherent methods on `PackageManager`: - `PackageManager::can_dispatch_node_scripts(self) -> bool` — Node ecosystem plus Deno. Replaces both duplicates above. - `PackageManager::has_exec_primitive(self) -> bool` — npm/pnpm/yarn/ bun/uv. Used by the arbitrary-command exec fallback to decide whether to honor a `--pm` override. Doc comments on both methods spell out the rationale (why Deno is included in one and excluded in the other) so the asymmetry stays discoverable instead of living in a one-off helper. No behavior change; the inherent-method form is identical to the free-functions it replaces.
`from_values` was introduced in the previous OverrideSources refactor
as a transitional `#[cfg(test)]` adapter so the 14 existing test sites
didn't have to migrate at the same time. With the new struct shape
proven, the positional-argument helper is now dead weight — a future
override source would still need to update its 7-arg signature plus
every call site.
All 14 test sites now construct `OverrideSources` directly:
ResolutionOverrides::from_sources(OverrideSources {
pm: SourceValue { cli: Some("yarn"), env: None },
..OverrideSources::default()
})
The shape is slightly more lines per call but each value is named, so
tests read as "what override is this exercising" rather than "what
position is this argument in."
`from_values` and its callsite comments are deleted. The test imports
gain `OverrideSources`, `SourceValue`, `ExplainSource`; the one test
that previously had a local `use` block (the ergonomics demo) drops
the redundant import since the module-level `use` covers it.
The scripted `PackageJson` arm already prints `decision.describe()` under `--explain`, but the no-task-match fallback (bun-test + pm-exec) ran the same resolver and threw the verdict away. Anyone debugging `runner run somebin --explain` against a manifest-pinned project saw the dispatch but not the chain step that picked the PM. Mirror the existing print in the fallback branch so both paths emit the same diagnostic line.
`completion_dir` only read `RUNNER_DIR` + cwd, so
`runner --dir /other-repo <TAB>` listed tasks from the shell's
working directory instead of `/other-repo`. Completion silently
disagreed with the dispatch that ran a moment later.
Add `cli_dir_from_argv` to scan the in-flight argv for `--dir`
(space + `=` forms, last occurrence wins to match clap) and thread
it into `resolve_completion_dir` ahead of the env var. The argv
walker skips past the first `--` because `clap_complete`'s bash
registration invokes the completer as `completer -- "${words[@]}"`;
direct invocation (no harness) still works because the start index
falls back to 1.
Five new tests pin: cli-over-env precedence, harness vs direct
invocation, equals form, last-wins for repeated flags, and the
None case.
`detect_pm_from_manifest` previously fell through to `devEngines` whenever `parse_package_manager_spec` returned `None`, including when the legacy `packageManager` field was present but malformed (typo like `pnpmm@9`, unsupported PM, lone `@9`). That silently substituted a PM the user never declared. Corepack treats `packageManager` as authoritative when present, so a present-but-unparseable value must NOT promote a different declaration. Trim the field, treat whitespace-only as "not set" (round-trips editor trailing-newline mishaps), and when the trimmed value is non-empty short-circuit with whatever `parse_package_manager_spec` returns — so unparseable → `None`, dropping the resolver to step 6 (lockfile) / step 7 (PATH probe) instead of synthesising a `devEngines` decision. Two regression tests pin both directions: unparseable spec blocks `devEngines`; whitespace-only spec still lets `devEngines` win.
The prior commit (`stop unparseable packageManager from electing
devEngines`) blocked the silent substitution, but the resolver still
dropped to lockfile / PATH probe with zero diagnostic. `doctor --json`
showed `manifest_pm: null` with no hint that the field was the cause,
so a user with a typo like `pnpmm@9` had to inspect their own
`package.json` to figure out why the declared PM was ignored.
Add `DetectionWarning::UnparseablePackageManager { raw }` and a new
`tool::node::detect_pm_field_with_diagnostics` helper that returns
`(Option<PackageManager>, Option<raw>)`. `detect_package_managers`
calls it and pushes the warning when the trimmed value is present
but non-parseable. Every surface that already prints `ctx.warnings`
(run / list / info / doctor / why) now echoes the typo back verbatim
with the accepted vocabulary on the same line:
warn: package.json: packageManager value "[email protected]" doesn't name
a script-dispatching package manager (expected one of
npm|pnpm|yarn|bun|deno, optionally followed by @<version>);
declaration ignored, falling back to lockfile / PATH probe
One regression test pins both invariants: the raw string appears
verbatim (so trailing whitespace / control chars survive) and the
accepted vocabulary is listed.
`simple_passthrough` tokenised with `split_whitespace`, which treats `\n` and `\r` as ordinary separators. A script body like `"just build\necho owned"` flattened to `["just", "build", "echo", "owned"]` and looked like a thin passthrough — the trailing `echo` is a separate shell command, not an argument forwarded to `just`, so the wrapper would have executed arbitrary follow-up commands while claiming to be a transparent dispatch. Bail on the first `\n` or `\r` before tokenising. Covers `\r\n` line endings from Windows editors too, since bash treats `\r` as its own token separator. Other control operators (`;`, `&&`, `||`, `|`) are already covered: spaced forms surface as tokens that `is_shell_active` rejects, and glued mid-token forms get caught by the any-position substring scan added in the prior guard commit. Three regression tests pin the newline, `\r\n`, and multi-line block forms.
`parse_package_manager_spec` collapsed `"pnpm@"` and bare `"pnpm"` into the same `(Pnpm, None)` result via `(!v.is_empty()).then(...)`. Both stayed "valid" — the `@` plus empty RHS silently disappeared. `"pnpm@"` is almost never intentional; it's `"[email protected]"` with the version chopped off. The earlier `UnparseablePackageManager` plumbing (`6c1d037`, `7c3b54b`) only fires when the parser returns `None`, so this typo never surfaced — the resolver picked Pnpm with no version constraint and the user never learned their pin was dropped. Match `Some((_, ""))` first and bail. Bare `"pnpm"` (no `@` at all) still parses as `(Pnpm, None)`; only the truncated form is rejected, which is the signal the detection-layer warning needs to fire. Three regression tests: trailing-`@` rejected for `pnpm`/`npm`, bare name still accepted, and the end-to-end `detect_pm_from_manifest` path returns `None` for `"packageManager": "pnpm@"`.
`runner.toml` had no schema, so editors with a TOML language server
(Taplo, Even Better TOML, etc.) couldn't validate or autocomplete the
file. Users discovered the schema by reading rustdoc.
Derive a JSON Schema via `schemars 1.2` instead of hand-writing one
(stays in sync with the Rust types automatically) and commit the
generated artifact at `schemas/runner.toml.schema.json` so editors
resolve it locally without a runtime dep on the binary or a
SchemaStore round-trip.
* Optional `schemars` dep + new `schema-gen` feature so production
builds don't pull it in. Gated `pub fn config_schema() ->
schemars::Schema` in `lib.rs` lets the example call
`schema_for!(config::RunnerConfig)` without permanently exposing
the internal config types.
* `examples/gen-schema.rs` writes to `schemas/runner.toml.schema.json`
with a trailing newline (so `git diff` stays clean) and prints a
one-line summary on stdout.
* `[schema] gen-schema` recipe in `justfile` + `cargo schema` alias
in `.cargo/config.toml` for ergonomic regeneration.
* `dprint` excludes `schemas/*.json` so the generated artifact
doesn't get reformatted by a different tool than the one that
produced it.
* Doc comments on every section become `description` fields; the
four closed-set `Option<String>` fields (`pm.node`, `pm.python`,
`resolution.fallback`, `resolution.on_mismatch`) carry inline
`enum` constraints via `schemars(extend("enum" = [...]))` so
editors show dropdowns. Schema is draft-2020-12, 3.4 KB.
`task_runner.prefer` array items stay free-form `string` rather
than constraining via `inner(...)` — that'd require switching the
field type to a `Vec<TaskRunnerLabel>` enum and lose the runtime
parser's "unknown runner X; expected one of …" error message. Doc
comment enumerates the valid values.
---
Plus: harden the passthrough detector against shell expansion
`is_shell_active` ignored bash globs (`*`, `?`, `[`, `]`), brace
expansion (`{a,b}`), and Windows `cmd.exe` env vars (`%VAR%`).
A `package.json` script body like `"build": "just build src/*.js"`
was misclassified as a thin passthrough even though bash expands
the glob into a file list before `just` ever sees it.
Substring-scan for `*`, `?`, `[`, `]`, `{`, `}`, `%` in any token
position. `(`, `)`, `!` stay exact-match — substring-matching them
would over-reject benign arg literals like `--filter=name(v1)`
where the shell wouldn't actually treat the paren as meta.
Six regression tests pin: glob star, glob `?`, character-class
glob, plain brace expansion, glued `--filter=name{a,b}` brace
expansion, and `%EXTRA_ARGS%`.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Cargo.toml`:
- Line 3: This PR introduces breaking changes so update the crate version in
Cargo.toml by bumping the MAJOR version value from "0.9.0" to a 1.x.y release
(e.g., "1.0.0"); locate the existing version = "0.9.0" entry and replace it with
the new MAJOR version string to follow the repo's SemVer rule.
In `@src/tool/passthrough.rs`:
- Around line 67-72: The simple_passthrough logic incorrectly allows commands
with extra plain positional tail args (e.g., "make build clean") to be treated
as thin passthroughs; update simple_passthrough to reject any command that
contains extra whitespace-separated tokens beyond the expected binary and
optional run_subcommand. Concretely, in simple_passthrough inspect the command
string (and/or its tokenized form) and return false if there are additional
positional tokens after the recognized binary and run_subcommand; keep using the
same symbols (simple_passthrough, command, binary, run_subcommand, name) so
callers/behavior remain unchanged except that commands with extra tail args are
no longer accepted as thin passthroughs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6d308438-d43e-4c13-9407-edeab57b10fd
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (9)
.cargo/config.toml.dprint.jsonCargo.tomlexamples/gen-schema.rsjustfileschemas/runner.toml.schema.jsonsrc/config.rssrc/lib.rssrc/tool/passthrough.rs
📜 Review details
⏰ Context from checks skipped due to timeout of 18000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Workers Builds: runner
🧰 Additional context used
📓 Path-based instructions (1)
@(package.json|pyproject.toml|setup.py|Cargo.toml|go.mod|pom.xml|build.gradle|VERSION)
📄 CodeRabbit inference engine (Custom checks)
@(package.json|pyproject.toml|setup.py|Cargo.toml|go.mod|pom.xml|build.gradle|VERSION): If any source code files (excluding tests, docs, CI, markdown, or comments-only changes) are modified, then a version field MUST be updated in one of the following files if present in the repo: package.json, pyproject.toml, setup.py, Cargo.toml, go.mod, pom.xml, build.gradle, or a VERSION file.
The new version MUST follow SemVer (MAJOR.MINOR.PATCH). If the PR introduces breaking changes (removal or renaming of public APIs, changes to function signatures, deleted exported symbols, or incompatible config changes), MAJOR must increment. If it adds backward-compatible functionality, MINOR must increment. If it only fixes bugs without changing public APIs, PATCH must increment.
Files:
Cargo.toml
🧠 Learnings (6)
📚 Learning: 2026-03-26T20:08:23.153Z
Learnt from: kjanat
Repo: kjanat/runner PR: 1
File: src/detect.rs:22-22
Timestamp: 2026-03-26T20:08:23.153Z
Learning: In `src/detect.rs` within the `kjanat/runner` codebase, `detect()` is intentionally infallible and should return `ProjectContext` directly. Parsing/I/O failures from task extractors (which return `anyhow::Result<Vec<String>>`) must be recorded by `push_extracted_tasks` into `ProjectContext.warnings` as `DetectionWarning` entries and then emitted to stderr via `cmd::print_warnings`. When reviewing, do not flag `detect()` for “swallowing errors” as long as failures are routed into the warnings-list design (i.e., they’re preserved in `ProjectContext.warnings`, not dropped silently).
Applied to files:
.dprint.json
📚 Learning: 2026-05-04T22:42:28.879Z
Learnt from: kjanat
Repo: kjanat/runner PR: 5
File: CHANGELOG.md:8-27
Timestamp: 2026-05-04T22:42:28.879Z
Learning: In `CHANGELOG.md`, do not treat a `## [Unreleased]` section as an error/mismatch during development PRs even if `Cargo.toml` includes an in-progress version bump. The versioned header format `## [X.Y.Z] - YYYY-MM-DD` should only be added at release/tag time; this repo’s `CHANGELOG.md` includes a “Post-release checklist” confirming that behavior. Only flag changelog/version mismatches if they contradict the intended workflow described in `CHANGELOG.md`.
Applied to files:
.dprint.json
📚 Learning: 2026-03-26T16:14:15.754Z
Learnt from: kjanat
Repo: kjanat/runner PR: 1
File: src/tool/go_task.rs:64-81
Timestamp: 2026-03-26T16:14:15.754Z
Learning: If code uses let-chains (e.g., `if let Some(x) = foo && ... && let Some(y) = bar`), ensure the crate’s `Cargo.toml` sets `package.edition = "2024"`. Rust 2021/earlier should not be used with let-chains; require 2024 specifically for compilation.
Applied to files:
Cargo.toml
📚 Learning: 2026-03-26T20:05:44.851Z
Learnt from: kjanat
Repo: kjanat/runner PR: 1
File: src/cmd/mod.rs:67-75
Timestamp: 2026-03-26T20:05:44.851Z
Learning: In Rust, `std::process::Command` does not provide getters for stdio configuration, so whether `Stdio::inherit()` (or other `Stdio::*` settings) was applied cannot be asserted via pure unit tests without spawning a process and inspecting OS-level fds. When reviewing Rust code, do not flag “missing unit test coverage” for stdio configuration on `std::process::Command` as a code issue—treat the explicit `Command::stdin/stdout/stderr` setter calls in the source as the meaningful guarantee.
Applied to files:
examples/gen-schema.rssrc/lib.rssrc/tool/passthrough.rssrc/config.rs
📚 Learning: 2026-04-21T15:16:40.277Z
Learnt from: kjanat
Repo: kjanat/runner PR: 4
File: src/tool/just.rs:132-133
Timestamp: 2026-04-21T15:16:40.277Z
Learning: In Rust, if a method like `ExtractedTask::name() -> &str` returns a borrowed `&str` tied to `&self`, then using `sort_unstable_by_key(|t| t.name())` should be avoided because `by_key` requires the key to not borrow from the element being sorted (it will fail to compile due to the key’s lifetime). Do not recommend `sort_unstable_by_key` as a simplification in this situation. If you need an allocation-free and idiomatic comparison, use `sort_unstable_by(|a, b| a.name().cmp(b.name()))` instead. Using `.to_owned()` inside `by_key` is an alternative but allocates a `String` per element.
Applied to files:
examples/gen-schema.rssrc/lib.rssrc/tool/passthrough.rssrc/config.rs
📚 Learning: 2026-05-04T23:28:17.947Z
Learnt from: kjanat
Repo: kjanat/runner PR: 5
File: src/lib.rs:222-223
Timestamp: 2026-05-04T23:28:17.947Z
Learning: When reviewing Rust `rustdoc` comments (`/// ...`), treat a trailing backslash (`\`) at the end of a comment line as valid CommonMark syntax for a hard line break (rendered as `<br>`). Do not flag such trailing backslashes as papercuts or recommend removing them unless there is clear evidence they are unintended (e.g., they are not in the `rustdoc` comment context or the surrounding formatting contradicts the intended CommonMark hard-break usage).
Applied to files:
examples/gen-schema.rssrc/lib.rssrc/tool/passthrough.rssrc/config.rs
🪛 GitHub Actions: PR `#22` / 0_Analyze (rust).txt
src/tool/passthrough.rs
[warning] 165-367: Rust build warning: macro expansion failed for 'assert_eq' / 'assert' (multiple occurrences; diagnostics suppressed after too many messages).
🪛 GitHub Actions: PR `#22` / Analyze (rust)
src/tool/passthrough.rs
[warning] 129-129: macro expansion failed for 'matches'
🔇 Additional comments (8)
.cargo/config.toml (1)
12-12: LGTM!Also applies to: 17-19
src/config.rs (1)
1-269: LGTM!src/lib.rs (1)
60-63: LGTM!Also applies to: 74-105, 185-186, 285-285, 313-329, 493-567, 583-591, 593-608, 739-740, 799-799, 845-879
.dprint.json (1)
31-32: LGTM!Cargo.toml (1)
54-57: LGTM!Also applies to: 62-62, 75-80, 101-106
examples/gen-schema.rs (1)
1-35: LGTM!justfile (1)
32-46: LGTM!schemas/runner.toml.schema.json (1)
1-106: LGTM!
`simple_passthrough` accepted any tail token that wasn't shell-active, including bare positionals. A script body like `"make build clean"`, `"just build release"`, or `"nx run build extra"` looked thin even though the trailing word changes what the underlying runner does: * `make build clean` — two targets; dispatching as a thin passthrough to `build` silently drops `clean`. * `just build release` — `release` is a recipe parameter, lost on dispatch. * `nx run build extra` — extra positional nx would forward, lost on dispatch. Tighten the predicate: every tail token must start with `-` (`-x`, `--flag`, `--flag=val`, the literal `--` separator) and still pass `is_shell_active`. Flags don't change which target the runner picks, only how it runs it; positionals do, so the wrapper can't claim to be transparent when they're present. Five regression tests pin: extra make target rejected, extra just positional rejected, extra nx positional rejected, `--flag=value` still accepted, and `--` separator still accepted.
PR #22 prepped the `[0.9.0]` section with a placeholder date and an out-of-date `[Unreleased]` compare link. Bump the date to release day and rewire compare links: `[Unreleased]` now compares against `v0.9.0`, new `[0.9.0]` link compares against `v0.8.1`.
Summary
A
resolvermodule that turns project detection signals + useroverrides into a single deterministic decision about which package
manager runs a
package.jsonscript. Replaces the silentnpmfallback baked into the resolver since day one with an explicit
8-step precedence chain, a typed error surface (distinct exit code),
and a structured diagnostic layer (
--explain,doctor --json,why <task>, typed warnings).The PR also lands the supporting CLI and config surfaces, a unified
JSON projection used by every machine-readable subcommand, a
OnceLock-backed$PATHprobe cache, and a hardened passthroughdetector for
package.jsonscripts that thin-wrap another runner.Resolution Chain
Step 5 wins over step 6 (Corepack semantics) with a configurable
mismatch policy. The PATH probe replaces the legacy silent
npmfallback; opt back into the old behavior with
--fallback npm.User-Facing CLI
Added to both
runnerandrunvia a sharedGlobalOptsstruct(
#[command(flatten)]):--pm <NAME>RUNNER_PM--runner <NAME>RUNNER_RUNNER--fallback <POLICY>RUNNER_FALLBACKprobe(default),npm,error--on-mismatch <POLICY>RUNNER_ON_MISMATCHwarn(default),error,ignore--explainRUNNER_EXPLAIN--no-warningsRUNNER_NO_WARNINGSNew subcommands:
doctor [--json]— full resolver-view dump for bug reports.why <task> [--json]— explain how a specific task name woulddispatch (candidate sources, ranking key, PM decision).
info --jsonandlist --json --source <FILTER>— machine-readableviews via the unified
Projectschema insrc/report.rs.Exit Codes
012ResolveError)main.rsmaps vialib::exit_code_for_errorso CI and shells candistinguish "intent could not be satisfied" from generic breakage.
Configuration File (
runner.toml)Lives at the project root only (no upward walk).
#[serde(deny_unknown_fields)]surfaces typos at parse time.Unknown PM names in
[pm]/[task_runner].preferare rejectedwith a helpful enumeration of valid values.
Typed Error + Warning Surface
ResolveError(src/resolver/error.rs) replaces ad-hocanyhow!strings:
NoSignalsFound { ecosystem, soft }— soft variant for theProbefallback that letscmd::runfall through to a directPATH spawn; hard variant for
--fallback=error.DevEnginesFailHard { pm, reason }—onFail=errorrejectedbinary-missing or version-mismatch.
MismatchPolicyError—--on-mismatch=errortriggered.InvalidOverride { value, reason }— cross-ecosystem--pm(e.g.
--pm cargofor Node scripts),--runner Xmatched nocandidate,
[task_runner].prefermatched no candidate.DetectionWarning(src/types.rs) migrated from a flat{ source, detail }struct to a typed enum, attributed per chainstep:
PmMismatch— declaration vs. lockfile disagreement.DevEnginesBinaryMissing/DevEnginesVersionMismatch—onFail=warnpaths.PathProbeFallback { picked, ecosystem, others_available }—resolver fell through to step 7.
LegacyNpmFallbackUsed—--fallback=npmtriggered.TaskListUnreadable { source, error }— detection-time parsefailure.
UnparseablePackageManager { raw }—package.jsondeclared apackageManagervalue that doesn't name a script-dispatching PM(typo, unsupported PM, trailing-
@); the verbatim string isechoed back with the accepted vocabulary.
Passthrough Detection (
src/tool/passthrough.rs)Detects when a
package.jsonscript is a thin wrapper for a taskrunner (
"build": "just build"), enabling completion dedup anddirect routing. Hardened against:
--watch&&echo,arg>out,arg|tee,trailing background
arg&. Substring-matches>,<,&,|,;in any token position; exact-matches(,),{,},!(substring would over-reject benign args like
--filter=name(v1)).\nand\rbefore tokenising —split_whitespacewould otherwise flatten compound statements(
"just build\necho owned") into apparent thin passthroughs.(
$VAR,$(cmd),`cmd`) — any-position substring check.Detection-Layer Hardening
packageManagerfield is authoritative: a present-but-unparseablevalue (typo
pnpmm@9, unsupported PMcargo@1, trailingpnpm@)now blocks
devEnginesfrom silently winning. Instead, thedetection layer emits
UnparseablePackageManagerechoing the rawvalue verbatim, and the resolver drops to lockfile / PATH probe.
packageManagervalues are treated as "not set"so trailing-newline editor mishaps round-trip cleanly.
Probe Cache (
src/resolver/probe.rs)$PATHprobe results cached in a static[OnceLock<Option<PathBuf>>; PackageManager::COUNT]array,indexed by a hand-rolled
PackageManager::index()(notas usize, so reordering the enum is a compile error rather thansilent cache corruption).
probe_allreturns all installed matches in order soPathProbeFallback'sothers_availablereflects the full installedset, not just the picked one.
Shell Completion
--dir <path> <TAB>now completes tasks from the target directory.Previously
completion_dir()only consultedRUNNER_DIR+ cwd,so completion silently disagreed with the dispatch that ran a
moment later.
cli_dir_from_argv()scans the in-flight argv(handling
clap_complete'scompleter -- words…harness) for--dir/--dir=…, matching the runtime resolver's precedence:CLI > env > cwd.
Unified JSON Schema (
src/report.rs)Single source-of-truth
Projectstruct that every--jsonsurfaceprojects from:
doctor --json→ full struct.info --json→ drops thetasksarray (info is a summary view).list --json [--source X]→TaskListView(justtasks, optionallyfiltered).
Schema-versioned via
Project::SCHEMA_VERSION = 1; bumping it is apublic-contract change.
Code Organization
#[allow(dead_code)]ratchets removed from the codebase entirely —every field and variant is now exercised by a real consumer.
Quality Gates
cargo test --all-targets).cargo clippy --all-targets --no-deps -- -D warningsclean.doctor,info --json,list --json --source justfile,--pm cargo(cross-ecosystem rejection),runner --dir <other-repo> <TAB>completion,pnpm@trailing-@warning.