Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Add resolver module for package manager and task runner selection#22

Merged
kjanat merged 44 commits into
masterfrom
claude/research-package-manager-precedence-BGwMO
May 13, 2026
Merged

Add resolver module for package manager and task runner selection#22
kjanat merged 44 commits into
masterfrom
claude/research-package-manager-precedence-BGwMO

Conversation

@kjanat

@kjanat kjanat commented May 12, 2026

Copy link
Copy Markdown
Owner

Summary

A resolver module that turns project detection signals + user
overrides into a single deterministic decision about which package
manager runs a package.json script. Replaces the silent npm
fallback 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 $PATH probe cache, and a hardened passthrough
detector for package.json scripts that thin-wrap another runner.

Resolution Chain

1. Qualified syntax              runner turbo.json:build
2. CLI flag                      --pm pnpm
3. Environment variable          RUNNER_PM=pnpm
4. Project config                runner.toml [pm].node = "pnpm"
5a. packageManager field         package.json (Corepack-compat)
5b. devEngines.packageManager    OpenJS proposal, honors onFail
6. Lockfile                      bun > pnpm > yarn > npm
7. PATH probe                    canonical order, OnceLock-cached
8. Terminal                      ResolveError → exit 2

Step 5 wins over step 6 (Corepack semantics) with a configurable
mismatch policy. The PATH probe replaces the legacy silent npm
fallback; opt back into the old behavior with --fallback npm.

User-Facing CLI

Added to both runner and run via a shared GlobalOpts struct
(#[command(flatten)]):

Flag Env Purpose
--pm <NAME> RUNNER_PM Force a specific package manager
--runner <NAME> RUNNER_RUNNER Restrict task dispatch to a runner
--fallback <POLICY> RUNNER_FALLBACK probe (default), npm, error
--on-mismatch <POLICY> RUNNER_ON_MISMATCH warn (default), error, ignore
--explain RUNNER_EXPLAIN One-line resolver trace on stderr
--no-warnings RUNNER_NO_WARNINGS Suppress non-fatal warnings

New subcommands:

  • doctor [--json] — full resolver-view dump for bug reports.
  • why <task> [--json] — explain how a specific task name would
    dispatch (candidate sources, ranking key, PM decision).
  • info --json and list --json --source <FILTER> — machine-readable
    views via the unified Project schema in src/report.rs.

Exit Codes

Code Meaning
0 Success
1 Generic failure (I/O, detection, child non-zero)
2 Resolver could not satisfy intent (ResolveError)

main.rs maps via lib::exit_code_for_error so CI and shells can
distinguish "intent could not be satisfied" from generic breakage.

Configuration File (runner.toml)

[pm]
node   = "pnpm"   # npm|pnpm|yarn|bun|deno
python = "uv"     # uv|poetry|pipenv

[task_runner]
prefer = ["just", "turbo"]  # restrictive, in listed order

[resolution]
fallback    = "probe"   # probe|npm|error
on_mismatch = "warn"    # warn|error|ignore

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].prefer are rejected
with a helpful enumeration of valid values.

Typed Error + Warning Surface

ResolveError (src/resolver/error.rs) replaces ad-hoc anyhow!
strings:

  • NoSignalsFound { ecosystem, soft } — soft variant for the
    Probe fallback that lets cmd::run fall through to a direct
    PATH spawn; hard variant for --fallback=error.
  • DevEnginesFailHard { pm, reason }onFail=error rejected
    binary-missing or version-mismatch.
  • MismatchPolicyError--on-mismatch=error triggered.
  • InvalidOverride { value, reason } — cross-ecosystem --pm
    (e.g. --pm cargo for Node scripts), --runner X matched no
    candidate, [task_runner].prefer matched no candidate.

DetectionWarning (src/types.rs) migrated from a flat
{ source, detail } struct to a typed enum, attributed per chain
step:

  • PmMismatch — declaration vs. lockfile disagreement.
  • DevEnginesBinaryMissing / DevEnginesVersionMismatch
    onFail=warn paths.
  • PathProbeFallback { picked, ecosystem, others_available }
    resolver fell through to step 7.
  • LegacyNpmFallbackUsed--fallback=npm triggered.
  • TaskListUnreadable { source, error } — detection-time parse
    failure.
  • UnparseablePackageManager { raw }package.json declared a
    packageManager value that doesn't name a script-dispatching PM
    (typo, unsupported PM, trailing-@); the verbatim string is
    echoed back with the accepted vocabulary.

Passthrough Detection (src/tool/passthrough.rs)

Detects when a package.json script is a thin wrapper for a task
runner ("build": "just build"), enabling completion dedup and
direct routing. Hardened against:

  • Glued shell meta-chars: --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)).
  • Multi-line scripts: rejects on \n and \r before tokenising —
    split_whitespace would otherwise flatten compound statements
    ("just build\necho owned") into apparent thin passthroughs.
  • Variable expansion / command substitution / arithmetic / backticks
    ($VAR, $(cmd), `cmd`) — any-position substring check.

Detection-Layer Hardening

  • packageManager field is authoritative: a present-but-unparseable
    value (typo pnpmm@9, unsupported PM cargo@1, trailing pnpm@)
    now blocks devEngines from silently winning. Instead, the
    detection layer emits UnparseablePackageManager echoing the raw
    value verbatim, and the resolver drops to lockfile / PATH probe.
  • Whitespace-only packageManager values are treated as "not set"
    so trailing-newline editor mishaps round-trip cleanly.

Probe Cache (src/resolver/probe.rs)

$PATH probe results cached in a static
[OnceLock<Option<PathBuf>>; PackageManager::COUNT] array,
indexed by a hand-rolled PackageManager::index() (not
as usize, so reordering the enum is a compile error rather than
silent cache corruption).

  • Exactly-once init per PM even under concurrent callers.
  • Zero allocation after start-up.
  • Never holds a lock during the syscall.

probe_all returns all installed matches in order so
PathProbeFallback's others_available reflects the full installed
set, not just the picked one.

Shell Completion

--dir <path> <TAB> now completes tasks from the target directory.
Previously completion_dir() only consulted RUNNER_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's completer -- words… harness) for
--dir/--dir=…, matching the runtime resolver's precedence:
CLI > env > cwd.

Unified JSON Schema (src/report.rs)

Single source-of-truth Project struct that every --json surface
projects from:

  • doctor --json → full struct.
  • info --json → drops the tasks array (info is a summary view).
  • list --json [--source X]TaskListView (just tasks, optionally
    filtered).

Schema-versioned via Project::SCHEMA_VERSION = 1; bumping it is a
public-contract change.

Code Organization

src/
├── cli.rs                 # CLI + GlobalOpts (flatten into Cli/RunAliasCli)
├── config.rs              # runner.toml loader
├── report.rs              # typed Project schema for --json output
├── resolver/
│   ├── mod.rs             # 8-step chain
│   ├── error.rs           # typed ResolveError
│   └── probe.rs           # OnceLock-cached PATH probe
├── tool/
│   ├── node.rs            # packageManager + devEngines parsing
│   └── passthrough.rs     # task-runner wrapper detection
├── types.rs               # DetectionWarning enum + Ecosystem
└── cmd/
    ├── doctor.rs          # new
    ├── why.rs             # new
    └── …

#[allow(dead_code)] ratchets removed from the codebase entirely —
every field and variant is now exercised by a real consumer.

Quality Gates

  • 408 tests passing (cargo test --all-targets).
  • cargo clippy --all-targets --no-deps -- -D warnings clean.
  • Smoke-tested end-to-end: doctor, info --json, list --json --source justfile, --pm cargo (cross-ecosystem rejection),
    runner --dir <other-repo> <TAB> completion, pnpm@ trailing-@
    warning.

kjanat added 8 commits May 12, 2026 19:32
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.
@kjanat kjanat marked this pull request as ready for review May 12, 2026 20:02
@kjanat kjanat self-assigned this May 12, 2026
@coderabbitai coderabbitai Bot added the enhancement New feature or request label May 12, 2026
`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.
@coderabbitai

coderabbitai Bot commented May 12, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8b495d99-d37b-4623-9db9-2cf3ad45e1d9

📥 Commits

Reviewing files that changed from the base of the PR and between 41e15a7 and 2887a5c.

📒 Files selected for processing (1)
  • src/tool/passthrough.rs
📜 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)
  • GitHub Check: verify
  • GitHub Check: Analyze (actions)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (rust)
🧰 Additional context used
🧠 Learnings (3)
📚 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:

  • src/tool/passthrough.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:

  • src/tool/passthrough.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:

  • src/tool/passthrough.rs
🔇 Additional comments (5)
src/tool/passthrough.rs (5)

1-21: LGTM!


22-57: LGTM!


59-118: Arrr, ye scurvy past concerns be properly keelhauled!

Ye fixed the multiline rejection (lines 86-88), and the tail-token check on line 117 now demands every token start with - before passin' the is_shell_active gauntlet. That means make build clean and just build release get rightfully rejected — no more silently droppin' extra targets or recipe parameters. This be exactly what me perfectionist soul craved, matey!

LGTM!


120-172: Shiver me timbers, this be a proper shell-metachar detector now!

Ye addressed all the prior grievances, ye did:

  • Glued operators like --watch&&echo and arg>out — caught via substring .chars().any() check (lines 143-148)
  • Globs and brace expansion like src/*.js and --filter=name{a,b} — caught (lines 160-165)
  • Windows %VAR% expansion — caught (line 134)

And ye kept !, (, ) as exact-match to avoid over-rejectin' benign literals like --filter=name(v1). That be the proper autistic attention to detail I respect!

LGTM!


174-434: Aye, this be a proper test suite fit for a perfectionist pirate!

Every edge case from them past reviews now has a testin' counterpart:

  • Extra make/just positionals (lines 367-381) — properly keelhauled
  • Glued shell operators (lines 253-298) — properly keelhauled
  • Globs and braces (lines 326-357) — properly keelhauled
  • Windows %VAR% (lines 359-365) — properly keelhauled
  • Multiline scripts (lines 300-324) — properly keelhauled

Seein' all these regression tests makes me crusty perfectionist heart sing a sea shanty! 🏴‍☠️

LGTM!


📝 Walkthrough

Introduce 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

  • Resolver: new src/resolver/ module implements an explicit resolution chain and provenance:
    • Resolution chain: qualified syntax → CLI flag → env var → runner.toml (project) → package.json.packageManager → devEngines.packageManager (with onFail/version checks) → lockfile precedence → PATH probe → legacy npm fallback → ResolveError.
    • Exposes ResolutionOverrides, ResolvedPm (with ResolutionStep provenance), FallbackPolicy, MismatchPolicy, PmOverride, RunnerOverride, and ResolveError typed variants.
    • Resolver::resolve_node_pm returns Result<ResolvedPm, ResolveError> and emits DetectionWarning instances for non-fatal diagnostics.
  • PATH probe: src/resolver/probe.rs provides a pure filesystem probe_in + memoized probe per PackageManager via OnceLock, PATHEXT-aware lookup on Windows, probe_all and NODE_PROBE_ORDER helpers.
  • Config: strict runner.toml parsing in src/config.rs (#[serde(deny_unknown_fields)]). Supports [pm], [task_runner], [resolution] only; provides parse_node_pm/parse_python_pm helpers and LoadedConfig wrapper.
  • Manifest/version checks: src/tool/node.rs adds Corepack-style packageManager parsing, detect_pm_field_with_diagnostics, devEngines.packageManager (object|array) selection with OnFail semantics, and VersionCheck that invokes --version and extracts a semver-like token for constraint checking.
  • Passthrough detection: src/tool/passthrough.rs implements conservative detection of thin wrappers for multiple runners (turbo, just, make, task, nx, bacon, mise), forbids shell-active constructs in tails, and accepts only flag-like tails; integrated into task deduping/completions.
  • Tool improvements: version-aware yarn exec_cmd (yarn 1 vs 2+), deno exec via deno x, uvx for uv, go run support, pipenv.install_cmd(frozen) → pipenv sync when frozen.
  • Reporting & UX:
    • New global CLI flags and env mirrors: --pm, --runner, --fallback, --on-mismatch, --explain, --no-warnings (RUNNER_PM, RUNNER_RUNNER, RUNNER_FALLBACK, RUNNER_ON_MISMATCH, RUNNER_EXPLAIN, RUNNER_NO_WARNINGS).
    • New subcommands: doctor [--json] and why [--json]; info/list gain JSON views and structured output.
    • Typed JSON Project schema and report model in src/report.rs and schemas/runner.toml.schema.json; Project::build produces unified schema for doctor/info/list/why.
    • ResolvedPm::describe() produces human explanation traces; warnings rendered consistently; --explain exposes provenance.
  • CLI plumbing: ResolutionOverrides constructed from CLI/env/config and threaded through cmd::run, cmd::list, cmd::info, doctor, why, and related commands; completion-time dir resolution mirrors runtime precedence.
  • Error codes: exit_code_for_error maps resolver-specific failures to exit code 2; generic failures map to 1; success is 0.
  • Tests & quality: large test expansion (reported 408 tests passing), unit tests for probe, precedence, mismatch/onFail, passthrough detection, yarn/pipenv behavior, report serialization; clippy/pedantic clean under stated profiles.

API / type changes

  • Task passthrough metadata: Task.passthrough_to: Option<TaskRunner] replaces passthrough_to_turbo: bool.
  • New Ecosystem enum and PackageManager/TaskRunner helpers (from_label, all, index, ecosystem/task_source).
  • DetectionWarning refactored into an enum with source/detail/Display.
  • Resolver types added as listed above.

Notable implementation notes

  • PATH probe is pure filesystem-only and memoized to minimize cost; probe_in is deterministic and PATHEXT-aware.
  • Passthrough detection tightened: tail tokens must be flag-like to qualify for passthrough (fixes regressions where extra positional args were dropped).
  • devEngines parsing supports object-or-array forms and computes default onFail semantics (download → Warn by default where applicable).
  • runner.toml parsing rejects unknown fields and is project-root-only.

Reviewer findings / required fixes before merge

  • Critical: Windows PATHEXT regression — code that spawns installed_version uses Command::new(pm.label()) which bypasses PATHEXT and can fail to spawn .cmd shims on Windows. Must switch to tool::program::command(pm.label()) (blocker for 0.9.0).
  • Diagnostic gap for Deno: selecting --pm deno can be accepted but diagnostics/fallback codepaths may silently drop the user's intent; either add an explicit Deno dispatch arm in the fallback path or remove Deno from pm_dispatches_scripts for that branch.
  • Env whitespace inconsistency: parse_override trims empty strings differently from is_env_truthy; recommend trimming env values consistently.
  • API ergonomics: ResolutionOverrides::from_values_with_explain currently takes many positional Option<&str> parameters — consider introducing an OverrideSources struct for clarity (deferable).
  • Tests gaps to address (follow-ups recommended):
    • Missing coverage for onFail = Error (binary missing / version mismatch paths); suggest refactoring to inject a BinaryChecker trait to make these branches testable.
    • No integration test exercising --pm cargo against a Node project (cross-ecosystem override).
    • why human renderer not snapshot-tested (only JSON asserted).
  • Tool-level follow-ups noted by audit:
    • yarn.exec_cmd now version-aware (addresses classic vs Berry).
    • pipenv.install_cmd now respects frozen (uses pipenv sync); other tools (poetry/composer) noted for potential follow-ups regarding frozen behavior.

Changelog / packaging

  • Bumped version to 0.9.0, added CHANGELOG.md notes, added schemars-backed schema generator example (examples/gen-schema.rs) and justfile gen-schema recipe.

Merge criteria

  • Fix the Windows PATHEXT regression (critical).
  • Address any CI/test failures; remaining diagnostics/API ergonomics can be follow-ups as noted by reviewers.

Walkthrough

Adds strict project runner.toml, a ResolutionOverrides-driven Resolver that picks Node package managers from CLI/config/manifest/lockfile/PATH, generalizes package.json passthrough detection, adds doctor/why diagnostics and JSON reports, new global resolver flags, and threads overrides through dispatch and run/list/info.

Changes

Configuration-Driven Package Manager Resolution

Layer / File(s) Summary
Release notes, schema & examples
CHANGELOG.md, Cargo.toml, examples/gen-schema.rs, justfile, schemas/runner.toml.schema.json
Bumps to 0.9.0, adds semver, optional schemars + schema-gen example and JSON Schema artifact.
Config schema & loader
src/config.rs
Adds strict runner.toml types (RunnerConfig, PmSection, ResolutionSection, TaskRunnerSection), LoadedConfig, load(), parse_node_pm, and parse_python_pm with tests.
Resolver core & errors
src/resolver/mod.rs, src/resolver/error.rs
Introduces Resolver, ResolutionOverrides, ResolvedPm, ResolutionStep, FallbackPolicy, MismatchPolicy, override parsing, resolve_node_pm, ResolveError, and extensive unit tests.
PATH probing
src/resolver/probe.rs
Adds memoized PATH probe (probe, probe_in, probe_all), NODE_PROBE_ORDER, PATHEXT handling, and tests.
Manifest PM detection & version checks
src/tool/node.rs
Adds Corepack-style packageManager parsing with diagnostics, detect_pm_from_manifest, ManifestPmDecl/OnFail, check_version_constraint using <pm> --version, manifest parsing updates, and tests.
Types & passthrough detection
src/types.rs, src/tool/passthrough.rs, src/detect.rs, src/tool/mod.rs
Adds Ecosystem, refactors DetectionWarning into enum, replaces Task.passthrough_to_turbo with passthrough_to: Option<TaskRunner>, adds conservative tool::passthrough::detect_target, and wires detection/completions to use it.
Reporting & diagnostics
src/report.rs, src/cmd/doctor.rs, src/cmd/why.rs, src/cmd/mod.rs
Adds typed Project JSON model and projections, doctor and why commands (JSON and human renderers), warning projection helpers, and tests.
cmd run/info/list & dispatch wiring
src/cmd/run.rs, src/cmd/info.rs, src/cmd/list.rs, src/lib.rs
Threads ResolutionOverrides through dispatch and command APIs; run uses resolver verdict for fallbacks and PM exec, unified candidate selection, info/list JSON modes, and updated grouping/rendering.
CLI flags & completions
src/cli.rs
Adds shared help styles, --pm/--runner/--fallback/--explain globals, RUNNER_* env mirrors, completion-dir argv parsing mirroring runtime precedence, and new Doctor/Why/Info/List flags.
Tool helpers & small wiring
src/tool/{deno,uv,go_pm,pipenv,yarn}.rs, src/cmd/install.rs, src/bin/run.rs, src/main.rs
Adds/updates exec helpers (deno x, uvx, go run, yarn exec/run version-aware), pipenv frozen flag, install mapping, and maps invocation errors to exit codes via exit_code_for_error. Tests updated/added.

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)
Loading

Possibly related PRs

  • kjanat/runner#13: Prior passthrough completion suppression work generalized by this PR.
  • kjanat/runner#4: Related alias handling and list/run grouping logic overlapping with this PR.
  • kjanat/runner#2: Related run/alias routing and run-alias CLI evolution; overlaps in dispatch/run wiring.

"Arr! The config be strict and probes be keen,
Warnings in tow and resolver sight unseen.
Flags hoisted sharp, completions tidy and sly,
Doctor and Why show the wherefore and why.
Tests green — splice the main, and let’s sail high!"

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch claude/research-package-manager-precedence-BGwMO

coderabbitai[bot]

This comment was marked as resolved.

kjanat added 4 commits May 12, 2026 20:09
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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented May 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

coderabbitai[bot]

This comment was marked as resolved.

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.
@kjanat

This comment was marked as outdated.

kjanat added 3 commits May 12, 2026 22:52
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.
kjanat pushed a commit that referenced this pull request May 12, 2026
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
kjanat added 2 commits May 12, 2026 23:01
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.
Repository owner deleted a comment from coderabbitai Bot May 12, 2026
coderabbitai[bot]

This comment was marked as resolved.

kjanat added 4 commits May 13, 2026 11:55
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.
coderabbitai[bot]

This comment was marked as resolved.

kjanat added 2 commits May 13, 2026 12:30
`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@"`.
coderabbitai[bot]

This comment was marked as resolved.

`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%`.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between c4c8183 and 41e15a7.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (9)
  • .cargo/config.toml
  • .dprint.json
  • Cargo.toml
  • examples/gen-schema.rs
  • justfile
  • schemas/runner.toml.schema.json
  • src/config.rs
  • src/lib.rs
  • src/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.rs
  • src/lib.rs
  • src/tool/passthrough.rs
  • src/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.rs
  • src/lib.rs
  • src/tool/passthrough.rs
  • src/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.rs
  • src/lib.rs
  • src/tool/passthrough.rs
  • src/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!

Comment thread Cargo.toml
Comment thread src/tool/passthrough.rs
`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.
@kjanat kjanat merged commit 7f3ef80 into master May 13, 2026
12 checks passed
@kjanat kjanat deleted the claude/research-package-manager-precedence-BGwMO branch May 13, 2026 12:25
kjanat added a commit that referenced this pull request May 13, 2026
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`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant