diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index cb7a268f..0f8698b5 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -24,17 +24,17 @@ Cargo aliases live in `.cargo/config.toml` (`t`, `cl`, `l`/`lint`, …). ## Generated artifacts -Both are rendered from the CLI definition behind off-by-default features — +Both are rendered from the CLI definition behind off-by-default features, never hand-edited. -- **`runner.toml` JSON Schema** — committed under `schemas/`: +- **`runner.toml` JSON Schema**, committed under `schemas/`: ```sh just gen-schema # cargo schema --output schemas/runner.toml.schema.json git diff --exit-code schemas/ # drift guard ``` -- **Man pages** (`man`/`schema` features) — generated at release time, not +- **Man pages** (`man`/`schema` features), generated at release time, not committed: ```sh diff --git a/.github/scripts/npm.sh b/.github/scripts/npm.sh index 8eb377c7..f98ae021 100755 --- a/.github/scripts/npm.sh +++ b/.github/scripts/npm.sh @@ -52,7 +52,7 @@ cmd_derive() { } # Install the packed tarballs into a scratch project and execute every -# bin — runs on the exact bytes `npm publish` ships. +# bin. Runs on the exact bytes `npm publish` ships. # Required env: RELEASE_TAG. cmd_smoke() { : "${RELEASE_TAG:?RELEASE_TAG required}" @@ -87,7 +87,7 @@ cmd_smoke() { local platform_dir="${scratch}/app/node_modules/${scope}/${host_pkg}" - # Raw binaries — the files whose exec bits the artifact handoff used to drop. + # Raw binaries, the files whose exec bits the artifact handoff used to drop. local raw_bins=("${platform_dir}/bin/"*) raw if [[ "${#raw_bins[@]}" -eq 0 ]]; then echo "error: no binaries under ${platform_dir}/bin/" >&2 @@ -120,12 +120,12 @@ cmd_smoke() { # still treat it as untrusted: defense-in-depth against a tampered # artifact at the cross-workflow handoff or a malicious tag committer. # Three defenses run before npm is invoked: -# 1. Hardcoded allowlist of expected directory names — a tampered +# 1. Hardcoded allowlist of expected directory names, a tampered # artifact cannot smuggle extra package directories. # 2. Each package.json's `name` field must equal the expected -# scope/key — prevents republishing as an unexpected package. +# scope/key, which prevents republishing as an unexpected package. # 3. Each package.json's `version` field must equal the version -# derived from the trigger tag (trusted metadata) — prevents +# derived from the trigger tag (trusted metadata), which prevents # stamping arbitrary versions onto allowed packages. # Single source of truth: npm/targets.json. `experimental: true` # packages may legitimately be missing because their build matrix uses @@ -159,7 +159,7 @@ cmd_publish() { EXPECTED_VERSION="${RELEASE_TAG#v}" # Refuse to proceed if the artifact contains anything outside the - # allowlist — that's either a misconfiguration or an attack. + # allowlist; that's either a misconfiguration or an attack. local allowed_set=" ${FACADE} ${REQUIRED_PLATFORMS[*]} ${OPTIONAL_PLATFORMS[*]} " local dir base for dir in npm/dist/*/; do @@ -170,7 +170,7 @@ cmd_publish() { fi done - # 0644 binaries EACCES at spawn — fail loud before publishing. + # 0644 binaries EACCES at spawn, fail loud before publishing. local platform bin for platform in "${REQUIRED_PLATFORMS[@]}" "${OPTIONAL_PLATFORMS[@]}"; do for bin in "npm/dist/${platform}/bin/"*; do @@ -198,7 +198,7 @@ cmd_publish() { # Tier-1/2 are always required: the artifact is built by release.yml's # build-dist (where missing tier-1/2 tarballs already fail loud), # so a missing dir here means the artifact was tampered with or the - # build silently dropped a target — either case warrants a hard fail. + # build silently dropped a target; either case warrants a hard fail. # Sub-packages first so the façade's optionalDependencies resolve on install. for platform in "${REQUIRED_PLATFORMS[@]}"; do publish_allowed "npm/dist/${platform}" "${SCOPE}/${platform}" true @@ -207,7 +207,7 @@ cmd_publish() { publish_allowed "npm/dist/${platform}" "${SCOPE}/${platform}" false done - # Façade is mandatory either way — no point publishing a half-empty + # Façade is mandatory either way, no point publishing a half-empty # set of platform packages with no entry point. publish_allowed "npm/dist/${FACADE}" "${FACADE}" true } @@ -263,7 +263,7 @@ publish_allowed() { # optionalDependencies validation. The facade is the only package that # legitimately ships optionalDependencies (one entry per built platform # package, all pinned to EXPECTED_VERSION). Platform packages must have - # none — a tampered platform package could otherwise smuggle attacker- + # none; a tampered platform package could otherwise smuggle attacker- # controlled deps that npm would happily install transitively. if [[ "${expected_name}" == "${FACADE}" ]]; then local dep_name dep_version platform dep_entries expected_dep_set=" ${REQUIRED_PLATFORMS[*]} ${OPTIONAL_PLATFORMS[*]} " @@ -306,7 +306,7 @@ publish_allowed() { echo "package-url=https://npm.im/package/${actual_name}/v/${version}" >>"${GITHUB_OUTPUT}" fi - # Skip if already published — npm versions are immutable, so reruns + # Skip if already published; npm versions are immutable, so reruns # after a partial publish would otherwise fail on the first # sub-package that already published. Bound the probe at 120s so a # hung registry can't stall the whole publish job. Non-timeout @@ -325,7 +325,7 @@ publish_allowed() { local args=(publish --registry "${REGISTRY}" --access public --tag "${DIST_TAG}" --ignore-scripts --provenance) if [[ "${DRY_RUN}" == "true" ]]; then args+=(--dry-run); fi - echo "+ npx -y npm@latest ${args[*]} (cwd: ${dir})" + echo "+ npx -y npm@11 ${args[*]} (cwd: ${dir})" # Tolerate the TOCTOU race between the npm view check above and # this publish: if another actor publishes the same version in # the gap, npm exits with EPUBLISHCONFLICT and we treat that as a @@ -335,12 +335,12 @@ publish_allowed() { # `output=$(cmd); status=$?` would exit on a failing cmd before # status was captured, and `if ! output=$(cmd); then status=$?` # captures the negation status (always 0), not npm's real exit - # code — silently masking real publish failures. + # code, silently masking real publish failures. local output status=0 - output=$(cd "${dir}" && timeout 120s npx -y npm@latest "${args[@]}" 2>&1) || status=$? + output=$(cd "${dir}" && timeout 120s npx -y npm@11 "${args[@]}" 2>&1) || status=$? if [[ "${status}" -eq 124 ]]; then printf '%s\n' "${output}" >&2 - echo "error: 'npx -y npm@latest publish' for ${actual_name}@${version} timed out after 120s" >&2 + echo "error: 'npx -y npm@11 publish' for ${actual_name}@${version} timed out after 120s" >&2 return 1 fi if [[ "${status}" -ne 0 ]]; then diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh index f28ca315..c58e4b45 100755 --- a/.github/scripts/release.sh +++ b/.github/scripts/release.sh @@ -42,7 +42,7 @@ cmd_package_asset() { for bin in runner run; do src="${BIN_DIR}/${bin}" if [[ ! -f "${src}" ]]; then - echo "error: ${src} not found — build step did not produce ${bin}" >&2 + echo "error: ${src} not found, build step did not produce ${bin}" >&2 exit 1 fi cp "${src}" "${staging}/${bin}" @@ -115,7 +115,7 @@ cmd_verify_asset() { if [[ "${#missing[@]}" -gt 0 ]]; then echo "error: build+upload for ${TARGET} reported success but these assets are not on release ${RELEASE_TAG}:" >&2 printf ' - %s\n' "${missing[@]}" >&2 - echo "the build step produced no artifact — inspect its log for a silent no-op." >&2 + echo "the build step produced no artifact; inspect its log for a silent no-op." >&2 exit 1 fi @@ -151,7 +151,7 @@ cmd_verify_checksums() { local tarballs=(*.tar.gz) local sums=(*.sha256) - # Every tarball needs a matching .sha256 and vice versa — otherwise an + # Every tarball needs a matching .sha256 and vice versa; otherwise an # unchecksummed binary could slip through to publish. if [[ "${#tarballs[@]}" -eq 0 ]]; then echo "error: no tarballs downloaded for ${RELEASE_TAG}" >&2 @@ -166,7 +166,7 @@ cmd_verify_checksums() { fi done - # Each .sha256 must reference a tarball matching its own basename — + # Each .sha256 must reference a tarball matching its own basename, # defends against a swapped reference leaving a tarball unchecked. for s in "${sums[@]}"; do inner=$(awk '{sub(/^\*/, "", $2); print $2}' "${s}") @@ -199,7 +199,7 @@ cmd_build_npm_packages() { [[ -d man ]] && man_arg=(--man-dir man) # build-packages.ts is tier-aware: missing tier-3 (experimental) - # tarballs are skipped, missing tier-1/2 fail the job. + # tarballs are skipped; missing tier-1/2 fail the job. node npm/scripts/build-packages.ts --version "${version}" "${man_arg[@]}" } diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 814cf250..b477beb3 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -46,7 +46,7 @@ jobs: platforms: ${{ steps.matrix.outputs.platforms }} facade: ${{ steps.matrix.outputs.facade }} steps: - # Sparse-checkout from default branch — no tag-supplied code on disk. + # Sparse-checkout from default branch, no tag-supplied code on disk. - uses: actions/checkout@v7 with: ref: ${{ github.event.repository.default_branch }} @@ -78,7 +78,7 @@ jobs: echo "error: no run-id resolvable for ${RELEASE_TAG}" >&2 exit 1 fi - # Numeric guard — blocks GITHUB_OUTPUT injection. + # Numeric guard, blocks GITHUB_OUTPUT injection. if ! [[ "${run_id}" =~ ^[0-9]+$ ]]; then echo "error: run-id '${run_id}' is not a positive integer" >&2 exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf1df2c4..a1fcb84b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -126,7 +126,7 @@ jobs: with: { sparse-checkout: npm/targets.json, sparse-checkout-cone-mode: false, persist-credentials: false } - id: gen - # `target: .rust` is mandatory — taiki-e and build-packages.ts both + # `target: .rust` is mandatory; taiki-e and build-packages.ts both # derive the asset filename `runner--.tar.gz` from it. # `vm`-typed targets need a dedicated job; filtered out here. run: | @@ -215,7 +215,7 @@ jobs: needs: [upload-assets, man] runs-on: ubuntu-latest if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') - permissions: { contents: write } # contents: write — drafts are invisible to read-only tokens. + permissions: { contents: write } # contents: write, drafts are invisible to read-only tokens. env: { RELEASE_TAG: "${{ github.ref_name }}" } steps: - uses: actions/checkout@v7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 64973989..bc2456e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,55 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ## [Unreleased] +### Added + +- `[install].on_collision` (`RUNNER_INSTALL_ON_COLLISION`) decides what + `runner install` does when two detected package managers write the same + directory. `"resolve"` (the default) installs with the PM already resolved + for that ecosystem (lockfile, `packageManager`, `[pm].node`) and skips the + other, printing which one it skipped; `"error"` refuses to pick and exits 2. + Under `"resolve"`, naming both writers in `[install].pms` still runs both; + `"error"` refuses the collision even when both writers are explicitly named. + +### Changed + +- Package managers that write the same install directory no longer install + concurrently. `bun install` and `deno install` over one `node_modules/` now + run one after another; managers with their own directories (cargo, uv, go) + still overlap. +- The install-dir collision warning is confined to `runner install` and + `doctor`, the surfaces that can act on it. It was reaching `run`, `list`, + `info`, and every nested runner process, none of which install anything. + `doctor` also reports the install plan (which PM installs a shared directory, + and which is shadowed) as a `conflicts[]` entry alongside duplicate task + names. +- Detection warnings are printed once per project, not once per runner process. + A `package.json` script that calls `runner` again (`"fmt": "runner run + lint:fix fmt:dprint"`) no longer repeats its parent's warnings. + +### Fixed + +- Deno counts as a `node_modules` writer in any project with a `package.json`, + not only one that sets `nodeModulesDir` explicitly. Deno's documented default + for a `package.json` project is the manual `node_modules` mode, so + `deno install` was writing the same tree as bun/npm/pnpm/yarn while runner + reported no collision at all. +- Two node lockfiles in one directory are settled by which one git tracks + before falling back to the fixed `bun > pnpm > yarn > npm` preference. A + project that commits `bun.lock` and gitignores `package-lock.json` is a bun + project, whichever way the preference order happens to point. Committed + status is the signal, not ignore status: a gitignored lockfile is ambiguous + (it can mean "we never commit lockfiles", which is evidence the manager *is* + used). One lockfile still answers by itself, and no git process is spawned + unless a directory holds two or more. +- A prerelease package-manager build (`bun@1.3.0-canary`) satisfies a + `devEngines` range like `>=1.2` instead of reporting a version mismatch. + Semver excludes prereleases from ranges that don't name one, which is correct + for a dependency solver and wrong for a "is the installed tool new enough" + check. +- `runner install` no longer orphans a running package manager when waiting on + another one fails. + ### Post-release checklist - [ ] Move completed `Unreleased` items into a new version section. @@ -44,7 +93,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic across a backspace) substitutes the token instead of pasting after it (`group_outputgroup_output =`). - `runner lsp` value completions inside an open string literal - (`prefer = ["ba`) insert the bare word instead of a quoted one — the + (`prefer = ["ba`) insert the bare word instead of a quoted one; the quotes are already typed (and auto-paired), so accepting previously produced `""bacon""`. - `runner lsp` is now comment-aware: no completions or hover at or after @@ -79,7 +128,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `task_runner.prefer`) is generated from the same types the resolver parses those values with, not hand-typed prose. A config field, or an accepted value for one of these, can no longer ship without scaffold - coverage — drift-guard tests fail the build instead. A few section + coverage; drift-guard tests fail the build instead. A few section descriptions read slightly differently as a result. `FallbackPolicy`, `MismatchPolicy`, and `ScriptPolicy` gained real `label()`/`ALL` (or `SETTABLE`) methods, replacing four separate hardcoded copies of their @@ -89,7 +138,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Removed - The v1/v2/v3 schema split. Not enough external adoption yet to justify - carrying three versions per surface — today's shape is the only one, + carrying three versions per surface. Today's shape is the only one, retroactively called v1. Committed schema files dropped their version suffix (`doctor.v3.schema.json` → `doctor.schema.json`, etc.); the 10 superseded schema/example files are deleted. @@ -97,12 +146,12 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `doctor --json` `overrides.fallback`, `on_mismatch`, `pm`, `pm_by_ecosystem`, `runner`, and `prefer_runners` are now closed enums in `doctor.schema.json` (with the accepted values documented per variant), - not generic strings — editors and validators can now catch a typo'd + not generic strings; editors and validators can now catch a typo'd override value against the committed schema instead of silently accepting anything. `pm_by_ecosystem`'s keys are constrained the same way: the schema now lists the seven ecosystem names explicitly instead of allowing any string key, and its values are plain (non-nullable) - package-manager labels — the report never emits a `null` there. + package-manager labels; the report never emits a `null` there. `failure_policy`, `script_policy`, and `install_pms` (new fields, see Added above) get the same closed-enum treatment from the start. @@ -113,14 +162,14 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic binary (the drift-guard test should already catch this before merge); it now reports a clean CLI error instead. - `doctor --json` `overrides.quiet` is now listed as required in - `doctor.schema.json`, like every other boolean override — it was kept + `doctor.schema.json`, like every other boolean override; it was kept optional for compatibility with the pre-collapse `doctor` v3 schema, which this same release already removed. - `runner lsp` diagnostics for a wrong-typed known field (e.g. `pms = "bun"`) now point at the offending value instead of line one. - `runner lsp` value completion for sequence-typed fields (`[install].pms`, `[tasks].prefer`, `[task_runner].prefer`) wraps the - first element as `["bun"]` when no `[` is typed yet — accepting a + first element as `["bun"]` when no `[` is typed yet; accepting a completion previously inserted a bare scalar, minting the exact type error above. Inside an open `[` the element stays bare. - `runner lsp` header completion after a dotted partial (`[tasks.`) now @@ -136,7 +185,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - The v0.18.0 npm packages spawn-failed with `EACCES`: the platform packages' new explicit `bin` field disabled `directories.bin` linking, so - npm no longer marked the native binaries executable at install — breaking + npm no longer marked the native binaries executable at install, breaking both `npx @runner-run/ …` and the `runner-run` facade. Platform `bin` entries now point directly at the native binaries and expose both commands (`npx --package=@runner-run/ runner …` and `… run …`); @@ -156,7 +205,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ships a package-relative launcher exposed as a single `runner` bin, so `npx @runner-run/ install -f task1 task2` and `npx --package=@runner-run/ runner list` work without the - `runner-run` facade — useful for platform-pinned installs (locked-down CI + `runner-run` facade, useful for platform-pinned installs (locked-down CI images) that don't want the facade's sibling platform packages in the resolution. One bin entry on purpose: npx auto-selects a package's sole bin; a second entry would force always naming the command. @@ -177,14 +226,14 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Added - FQN task syntax: the `root:#` identity that `doctor --json` / - `why --json` print for a task is now runnable — + `why --json` print for a task is now runnable; `run 'root:package.json#deno:importsmap'` (the `root:` scope prefix is optional) dispatches that exact task. Every source label of every schema version round-trips, including v3's `cargo-alias`, which previously named a task no syntax could invoke. -- `runner why` interprets its argument exactly like `run` does — qualified +- `runner why` interprets its argument exactly like `run` does, qualified syntax (`why deno:lint`), FQN (`why root:package.json#build`), and the - colon-name fallback below — so it explains the very dispatch `run` would + colon-name fallback below, so it explains the very dispatch `run` would perform instead of reporting "no candidates" for tokens `run` accepts. - Makefile descriptions from the inline self-documenting form (`build: deps ## Build the project`), the idiom `##` help targets are built @@ -195,7 +244,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - A qualified or FQN miss (`deno:nope`, `package.json#nope`) is now a hard error in every path. Previously an FQN token fell through to the PM-exec fallback, where bunx/npx treated it as a package spec and resolved it off - the network — a typo could hang on registry resolution or download an + the network; a typo could hang on registry resolution or download an arbitrary package. Bare unmatched names still fall through; `user/repo#ref` package specs still work. - Single runs and chain pre-validation (`run -p`/`-s`) report a qualified @@ -204,13 +253,13 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic misleading `did you mean …?` hint). - A CLI chain-failure flag now beats the opposite polarity from a lower layer: `run -s a b -k` with `[chain] kill_on_fail = true` in `runner.toml` - keeps going instead of aborting with a cross-source conflict — the config + keeps going instead of aborting with a cross-source conflict; the config polarity had no command-line escape hatch. Same-source conflicts (`-k -K`, both env vars, both config keys) still error. - Boolean `RUNNER_*` env vars (`RUNNER_QUIET`, `RUNNER_EXPLAIN`, `RUNNER_NO_WARNINGS`, `RUNNER_KEEP_GOING`, `RUNNER_KILL_ON_FAIL`) warn and are ignored when set to an unrecognized token. `RUNNER_KEEP_GOING=flase` - (typo'd "false") used to silently read as truthy — the opposite of the + (typo'd "false") used to silently read as truthy, the opposite of the intent. Recognized, case-insensitive: `1`/`true`/`yes`/`on` and `0`/`false`/`no`/`off`. @@ -287,15 +336,15 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic the CLI, so editor feedback never drifts from `runner` itself. - `[tasks]` section in `runner.toml` for a persistent, declarative preference over which source runs an ambiguous task name (one that exists under more than - one source — e.g. a `package.json` script *and* a `turbo` task). Previously + one source, e.g. a `package.json` script *and* a `turbo` task). Previously this could only be expressed per-invocation (`package.json:build`, `--pm bun`, `--runner turbo`). - - `[tasks].prefer` — a rank-only global order. Labels may be task runners + - `[tasks].prefer`, a rank-only global order. Labels may be task runners (`turbo`, `make`, …), package managers (`bun`, `npm`, … map to `package.json`; `deno` → `deno.json` then `package.json`), or source names (`package.json`). Unlike the old `[task_runner].prefer`, it never - hard-rejects an unlisted source — it only reorders ties. - - `[tasks.overrides]` — per-task pins, e.g. `build = "turbo"`, `dev = "bun"`, + hard-rejects an unlisted source; it only reorders ties. + - `[tasks.overrides]`, per-task pins, e.g. `build = "turbo"`, `dev = "bun"`, that beat the global order for those names. - An explicit `source:task` qualifier, `--runner`, or `--pm`/`RUNNER_PM` still outranks these file settings. @@ -312,7 +361,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Added -- `runner install` gained a two-way install-time lifecycle-script control — +- `runner install` gained a two-way install-time lifecycle-script control: lifecycle/build scripts are the primary supply-chain attack surface during dependency installs, and several package managers (npm, pnpm, …) are moving to scripts-off-by-default in upcoming majors, so projects need to deny *and* @@ -327,9 +376,9 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic and a bare `--allow-scripts` (allow all) for deno. Managers that already run scripts by default (composer, cargo, go, bundler, uv/poetry/pipenv, yarn-classic) are satisfied without a flag. bun and pnpm (>=10) can't be - forced on by a flag — their dependency build scripts are gated by a manifest + forced on by a flag; their dependency build scripts are gated by a manifest allowlist (`trustedDependencies` / `onlyBuiltDependencies`) that runner won't - write — so they `warn:` instead of silently no-op'ing. + write, so they `warn:` instead of silently no-op'ing. - The two flags are mutually exclusive. Both the dropped-deny and the unforceable notices fire whenever their policy is active and are *not* silenced by `--no-warnings` / `RUNNER_NO_WARNINGS` (unlike the cosmetic @@ -347,25 +396,25 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic (`bin/tool`) are each run as the file when they match no task: a recognized source file runs via the detected runtime (`.ts`/`.mts`/`.cts`/`.js`/`.mjs`/`.cjs` via bun, `deno run`, or node, while - `.jsx`/`.tsx` run only via bun or deno — Node has no JSX transform, so a + `.jsx`/`.tsx` run only via bun or deno; Node has no JSX transform, so a node-only project reports a clear `node cannot run` error instead of building an unrunnable `node app.tsx`; `.py` via `uv run` or python; `.go` via `go run`), a `#!` shebang (including the `#!/usr/bin/env -S ` form, whose quoted arguments are kept intact) is parsed and invoked, and a native binary or self-executable - script is spawned directly — including an execute-only binary (Unix mode + script is spawned directly, including an execute-only binary (Unix mode 0111), whose unreadable shebang probe is treated as "no shebang" so the binary still spawns directly rather than hard-failing the run. A source file - carrying the exec bit but no shebang still runs via its runtime — a raw - `execve` on shebang-less text fails `ENOEXEC` — so `chmod +x deploy.ts; + carrying the exec bit but no shebang still runs via its runtime; a raw + `execve` on shebang-less text fails `ENOEXEC`, so `chmod +x deploy.ts; run ./deploy.ts` dispatches `bun deploy.ts` rather than erroring (this also fixes whole-tree breakage on vfat/exfat/ntfs-3g mounts that report mode 0777 for every file). Only an explicit-prefix path outranks a same-named task; a prefix-less `bin/tool` lets a matching `make bin/tool` target win first and runs as a file only after task lookup misses. A missing explicit path reports a clear error rather than a 404. Path lookup is anchored on the - resolved project directory (the `--dir`/`RUNNER_DIR` target, else the cwd) — - the same directory task detection scans and the spawned child runs in — so a + resolved project directory (the `--dir`/`RUNNER_DIR` target, else the cwd), + the same directory task detection scans and the spawned child runs in, so a relative or bare token under `--dir` resolves there instead of silently missing and mis-routing back into the package-exec 404 path. - Chain mode now reports per-task wall-clock duration on completion. Sequential @@ -383,7 +432,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic (`RUNNER_NO_WARNINGS`) suppress it. - `runner install -p ` runs the post-install tasks in parallel (`-s` stays the default sequential). Install always runs first as the - prerequisite — never as a parallel sibling — then the tasks fan out. A + prerequisite, never as a parallel sibling; then the tasks fan out. A failed install still aborts the tasks unless `-k`; `-K` (kill siblings) now bites for the parallel post-install phase. @@ -396,8 +445,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic forced PM (`RUNNER_PM=deno run check` → `deno task check`), which breaks when the script relies on npm lifecycle build artifacts deno cannot honor. Now the forced PM's own source wins the conflict, most-native first: `RUNNER_PM=deno` - picks `deno:check`, `--pm bun` picks `package.json:check`. The rule is general - across every PM — deno is one member, not a special case — and a PM that owns + picks `deno:check`; `--pm bun` picks `package.json:check`. The rule is general + across every PM; deno is one member, not a special case, and a PM that owns no task source (Bundler, Composer) re-orders nothing. Only conflicting same-name candidates are re-ordered; runs with no `--pm`/`RUNNER_PM` are unchanged. See https://github.com/kjanat/runner/issues/70. @@ -415,7 +464,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - A leading `~`/`~/` in `--dir` (or `RUNNER_DIR`) is now expanded to the user's home directory before the project directory is resolved. Shells only expand an unquoted tilde at the start of a word, so `--dir=~/foo` - reached `runner` verbatim and was treated as relative — joined onto the + reached `runner` verbatim and was treated as relative, joined onto the cwd to produce a bogus `/~/foo` that never exists. Unsupported forms such as `~user` are left untouched, and the path passes through unchanged when no home directory is set. @@ -430,7 +479,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic install to one. A listed-but-undetected PM errors. `--pm`/`RUNNER_PM` still takes precedence; `[pm]` continues to scope only script dispatch. - `runner install` and `doctor` now warn when two detected package managers - would install into the same directory — today `node_modules` (a node PM + would install into the same directory, today `node_modules` (a node PM plus a `nodeModulesDir`-enabled Deno). The warning points at `[install].pms` and is suppressed once the allowlist narrows install to a single writer. @@ -448,7 +497,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic field (a typo, or a key written by a newer `runner`) is ignored with a warning instead of aborting the command. Previously an unknown key was a hard parse error, so a config written by one version could brick task - dispatch — including postinstall `run` hooks — under another. Genuine + dispatch, including postinstall `run` hooks, under another. Genuine errors (unreadable file, malformed TOML, wrong type on a known field) still fail. The JSON Schema stays strict (`additionalProperties: false`) so editors keep flagging typos inline. @@ -469,7 +518,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Changed - `config validate` rejects a `[chain]` that sets both `keep_going` and - `kill_on_fail` true — the resolver already errored on this combination at + `kill_on_fail` true; the resolver already errored on this combination at dispatch time; validation now catches it statically against the file alone. - JSON Schema URLs rehosted from `https://kjanat.github.io/schemas/…` to @@ -511,8 +560,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Changed - Built-in verb dispatch is split between the two surfaces. The explicit - `runner ` subcommand — `install`, `clean`, `list`, `info`, - `completions` — is now **always** the built-in and is never shadowed by a + `runner ` subcommand, `install`, `clean`, `list`, `info`, + `completions`, is now **always** the built-in and is never shadowed by a same-named project task. The run path (`run ` / `runner run `) runs a same-named task when one exists, and otherwise falls back to that built-in's default form instead of the package-manager exec path (so @@ -528,8 +577,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - The `run` alias now forwards `--help`/`-h` and `--version`/`-V` to the task when they follow a task name: `run --help` reaches the task's own help instead of printing `run`'s (previously `run --` - was required). `run --help`/`--version` with no task — including after - global flags like `run --pm npm --help` — still print this binary's own + was required). `run --help`/`--version` with no task, including after + global flags like `run --pm npm --help`, still print this binary's own help/version, and `run -- --help` still forwards literally. The `runner run` subcommand is unchanged. Because `-h`/`--help`/`-V`/ `--version` are no longer clap arguments on the alias, they are @@ -540,7 +589,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Added - `runner doctor --json` schema **v3** (now the default for `doctor`): - the flat detection dump becomes a structured diagnostic inventory — + the flat detection dump becomes a structured diagnostic inventory, `invocation`/`environment`/`runner` provenance, per-`ecosystems` decisions with a `confidence` grade derived from the resolution step (override/manifest/lockfile → high, PATH probe → medium, legacy npm @@ -557,7 +606,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `runner why --json` schema **v3** (now the default for `why`): the report is restructured around `{task, match}` candidate pairs plus a `decision` block. Each task carries a stable identity - (`fqn` = `root:#`, `provider`, `kind` — cargo aliases are + (`fqn` = `root:#`, `provider`, `kind`, cargo aliases are now labeled `cargo-alias`), its origin (`source` file, `source_pointer` key path), and resolution data (`definition`, `resolved` command preview, `cwd`, sibling `aliases`, @@ -566,7 +615,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `decision.strategy` names the branch taken (`single-candidate`, `ranked`, `filtered`, `exec-fallback`). Implements the former `why.v3-draft` example, which the real output now reproduces verbatim; - v1/v2 stay available via `--schema-version`. `list` remains at v2 — its + v1/v2 stay available via `--schema-version`. `list` remains at v2, its v3 draft is still under review, and it rejects `--schema-version 3` rather than mislabel output. `schema --all` emits the committed `schemas/why.v3.schema.json`, and the example validates against it. @@ -589,8 +638,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `runner list` and the bare `runner` view now print a duplicate-name conflict footer. When two sources define the same task name (e.g. a `just` `run` recipe and `cargo run`), it names the source that - `runner run ` actually dispatches and the ones it shadows — using - the same precedence as dispatch — so a silently shadowed task no longer + `runner run ` actually dispatches and the ones it shadows, using + the same precedence as dispatch, so a silently shadowed task no longer goes unnoticed. ### Changed @@ -601,7 +650,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `remove` tasks (e.g. `test (t)`) instead of standing alone; both the canonical name and the short form still dispatch. Aliases that carry extra arguments (`bb`, `cl`, `rq`, …) keep their own rows. Promoting - `run`/`remove` can collide with a same-named `just`/other task — that + `run`/`remove` can collide with a same-named `just`/other task; that collision now surfaces in the conflict footer above rather than hiding. - `runner doctor --json` (v3) now probes package-manager and task-runner versions via ` --version` (previously only the Node runtime @@ -625,7 +674,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `npm= -> (volta)`, or `(volta shim, not provisioned)` when Volta fronts a tool it has no version of. JSON gains an additive `signals.node.volta_shims` map - (omitted on hosts without Volta; no schema bump). Display only — + (omitted on hosts without Volta; no schema bump). Display only, execution still spawns the shim, which performs Volta's per-project version selection. @@ -633,7 +682,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `runner install` now honors the `--pm`/`RUNNER_PM` override: when set, only that package manager installs (previously the override was - ignored and every detected PM installed — e.g. a project with both + ignored and every detected PM installed, e.g. a project with both `bun.lock` and `deno.json` always ran `deno install` too, writing an unwanted `deno.lock`). An override naming a PM that detection did not find refuses the install with exit code 2. runner.toml @@ -641,7 +690,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - Invalid `--pm`/`RUNNER_PM`/`--runner`/`RUNNER_RUNNER` values now produce a readable error: the message names the source that carried the value, escapes control characters (no more raw ANSI codes), truncates long - garbage, and — when the value contains line breaks — hints that it + garbage, and, when the value contains line breaks, hints that it looks like captured command output with the correctly quoted PowerShell spelling. (An unquoted `$env:RUNNER_PM=deno` executes deno and assigns its REPL banner to the variable.) @@ -649,10 +698,10 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Fixed - `runner doctor` no longer dies when a `RUNNER_*` override variable - holds an unparseable value — the condition it exists to diagnose. The + holds an unparseable value, the condition it exists to diagnose. The invalid value is ignored for the report and surfaced as an `env:` warning (human output and the `warnings` array of `doctor --json`, - additively — no schema bump). Every other command, and an explicit bad + additively, no schema bump). Every other command, and an explicit bad `--pm`/`--runner` flag even on doctor, still fails fast. - Node version constraints are now evaluated with real range semantics (via the `semver` crate) instead of a prefix match that treated @@ -666,8 +715,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - Task dispatch now prepends every existing `node_modules/.bin` between the project directory and the filesystem root (nearest first) to the child's `PATH`, the way `npm run` / `pnpm run` / `bun run` do for - `package.json` scripts. Tools that runner spawns directly — `turbo` - for `turbo.json` tasks, and the bare-binary exec fallback — used to + `package.json` scripts. Tools that runner spawns directly, `turbo` + for `turbo.json` tasks, and the bare-binary exec fallback, used to inherit the shell's `PATH` unchanged, so a devDependency-only `turbo` failed with `Error: No such file or directory (os error 2)` unless it was also installed globally. On Windows, bare program names are @@ -689,7 +738,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic OS-specific code, so the gate bought nothing and is gone. - `install.sh` runs under any POSIX `sh`. It carried a `#!/usr/bin/env bash` shebang, but `curl … | sh` ignores the shebang, so the bash-only - `set -o pipefail` aborted on line 2 under dash/busybox — the default + `set -o pipefail` aborted on line 2 under dash/busybox, the default `/bin/sh` on the `-musl` targets. Rewritten POSIX-clean. It also picks the install dir more intelligently now: reuse an already-installed runner's directory (verified by its `-V` banner, so a system `run`/ @@ -716,14 +765,14 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic scripts) are now extracted as runnable tasks for Python projects. They surface under the `pyproject.toml` source in `runner list` (with the entry-point target shown as the description) and dispatch via the - detected Python package manager's `run` subcommand — `uv run `, + detected Python package manager's `run` subcommand, `uv run `, `poetry run `, or `pipenv run `. Previously a uv/poetry project's declared scripts were invisible to `runner`, which detected the package manager but listed no tasks. - AUR distribution channel. Two packages on the Arch User Repository: `runner-run-bin` (prebuilt binaries for `x86_64`, `aarch64`, `armv7h`) and `runner-run` (source build for `x86_64`, `aarch64`). `-bin` - `provides`/`conflicts` `runner-run`, so install whichever you prefer — + `provides`/`conflicts` `runner-run`, so install whichever you prefer, https://aur.archlinux.org/packages/runner-run-bin and https://aur.archlinux.org/packages/runner-run. - Shell completions shipped by both AUR packages and auto-loaded from @@ -733,7 +782,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `/usr/share/fish/vendor_completions.d/{runner,run}.fish`. PowerShell on Linux has no autoload convention, so the pwsh script is installed at `/usr/share/runner/runner.ps1` for users to dot-source from their - `$PROFILE`. Completions are clap-dynamic — the shell shells out to + `$PROFILE`. Completions are clap-dynamic; the shell shells out to the binary for candidates, so tab-completing in a project picks up the *current* task list from `package.json` / `turbo.json` / `Justfile` / etc., not a static snapshot. @@ -752,7 +801,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic runs. - Man pages for `runner`, `run`, and each subcommand. Rendered from the clap command tree by a `man` subcommand gated behind the `man` - feature (off by default — never in the shipped binary, never committed) + feature (off by default, never in the shipped binary, never committed) and shipped by every channel: crates.io (in the published crate), npm (facade `man` field), both AUR packages (`/usr/share/man/man1/`), and a `runner--man.tar.gz` GitHub release asset that `install.sh` and @@ -799,12 +848,12 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `[github].group_output` in `runner.toml` (default `true`). - Grouped parallel (`-p`) output. Each task's stdout/stderr is captured and printed as one contiguous `runner: ` block when that task - finishes (completion order — first done, first shown), instead of + finishes (completion order, first done, first shown), instead of interleaving lines live. Under GitHub Actions the block is a `::group::` section; elsewhere it gets a plain colored header. Defaults diverge by environment so CI and local can differ: `[github].group_parallel` (default `true`, only when `[github].group_output` is also `true`) - governs runs under GitHub Actions, `[parallel].grouped` (default + governs runs under GitHub Actions; `[parallel].grouped` (default `false`) governs runs elsewhere. Opting out on either path restores the live `[]`-prefixed multiplexer. - `[github]` and `[parallel]` sections in `runner.toml`, reflected in the @@ -829,13 +878,13 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic exit code reflecting the first failure. `--kill-on-fail` (parallel only) terminates siblings immediately when one fails. `-k` and `--kill-on-fail` are mutually exclusive across CLI, - env, and config — conflicting layers surface + env, and config; conflicting layers surface `ResolveError::ConflictingFailurePolicy` with the offending source named. - `[chain]` section in `runner.toml` plus `RUNNER_KEEP_GOING` / `RUNNER_KILL_ON_FAIL` env-var mirrors. Same resolver-chain precedence as the rest of the policy knobs: CLI > env > config. - Env layer is presence-authoritative — `RUNNER_KEEP_GOING=0` + Env layer is presence-authoritative; `RUNNER_KEEP_GOING=0` overrides `[chain].keep_going = true` in config, not just the default. - Line-prefix multiplexer for parallel chain output. Each task's @@ -889,7 +938,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Added - mise task extraction and dispatch. `mise` was previously - detection-only — `runner` listed it under "Task Runners" but its + detection-only; `runner` listed it under "Task Runners" but its tasks were invisible to `runner list` and `runner run `. New `TaskSource::MiseToml` makes mise a first-class source: tasks declared in `mise.toml` / `.mise.toml` (and the `*.local.toml`, @@ -897,7 +946,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic documented precedence) appear in listings, participate in the selection priority, and dispatch via `mise run `. - Bacon-style two-tier extraction for mise. Primary path shells - out to `mise tasks --json` — authoritative across mise's config + out to `mise tasks --json`, authoritative across mise's config layering and file-based tasks (`mise-tasks/*`); fallback parses the first project-local config when `mise` isn't on `$PATH`. Both paths exclude hidden tasks (`hide = true`), @@ -921,7 +970,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `ctx.root` before it considers the canonical Node order. Without that evidence the resolver returns the existing soft `NoSignalsFound` sentinel and `cmd::run::run_pm_exec_fallback` - spawns the target directly on `$PATH` — no more wrong-ecosystem + spawns the target directly on `$PATH`, no more wrong-ecosystem dispatch. ## [0.9.0] - 2026-05-13 @@ -929,10 +978,10 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Added - Unified package-manager resolution chain. `runner run` now follows a - documented 8-step precedence — qualified syntax → `--pm` / `--runner` + documented 8-step precedence, qualified syntax → `--pm` / `--runner` → `RUNNER_PM` / `RUNNER_RUNNER` → `runner.toml` → `package.json` (`packageManager` then `devEngines.packageManager`) → lockfile → - `PATH` probe → terminal error — making toolchain selection + `PATH` probe → terminal error, making toolchain selection predictable across Corepack, antfu/ni, mise, and pnpm v11+ conventions. New `src/resolver/` module owns the chain end-to-end. - `--pm` / `--runner` global flags with `RUNNER_PM` / `RUNNER_RUNNER` @@ -986,7 +1035,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Changed - `Task.passthrough_to_turbo: bool` replaced by `Task.passthrough_to: - Option` so wrappers around any runner — not just turbo — + Option` so wrappers around any runner, not just turbo, can be attributed at detection time and used by completion. - `cmd::run::run` signature now takes a `&ResolutionOverrides` so the resolver-chosen PM also flows through the no-task fallback paths @@ -1035,7 +1084,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `_` are treated as private and hidden. When the `bacon` CLI is on `PATH`, extraction shells out to `bacon --list-jobs` so bacon's built-in jobs (`check`, `clippy`, `test`, …) merge into the listing alongside - whatever `bacon.toml` declares — same view bacon itself presents. Falls + whatever `bacon.toml` declares, same view bacon itself presents. Falls back to TOML parsing when bacon isn't installed. Job arguments forward through bacon's `--` separator (`runner run test -- --ignored` → `bacon test -- --ignored`) so they reach the underlying job intact. @@ -1044,7 +1093,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `cargo binstall runner-run` support via `[package.metadata.binstall]` in `Cargo.toml`. cargo-binstall now downloads the prebuilt binary from the matching GitHub release asset (`runner-v{version}-{target}.tar.gz`) - instead of building from source — same archives + instead of building from source, same archives `taiki-e/upload-rust-binary-action` uploads from `release.yml`. Both `runner` and `run` install side by side, no toolchain required. @@ -1055,14 +1104,14 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic fires in parallel with binary builds and no longer waits on the npm publish chain to complete first. `release.yml` gains a final `publish-release` job that flips the draft GitHub release to - published once binaries and the `dist` artifact land — this is + published once binaries and the `dist` artifact land; this is now the natural pivot of the release lifecycle and drives `npm-release.yml` via `release: published`. `npm-release.yml` drops its `workflow_run` trigger (and the draft-flip side job that was hidden in it), resolving the build run-id for cross-workflow artifact download via `gh run list` instead. Net effect: tag push alone ships crates.io immediately, and the GH release auto-publishes - once binaries are ready — no more manual draft-flipping. + once binaries are ready, no more manual draft-flipping. - `npm/facade/README.md` updates the install fallback instructions to `cargo install runner-run` (crates.io) instead of the git-source form, matching the 0.7.1 README/landing-page change. @@ -1096,8 +1145,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic surfaced parse errors. The qualified-task syntax also accepts `turbo.jsonc:task` (and `deno.jsonc:task`, fixed in the same line for parity). Fixes #10. -- Root Tasks in `turbo.json` — entries written with the `//#name` - prefix, invoked via `turbo run name` against the workspace root — +- Root Tasks in `turbo.json`, entries written with the `//#name` + prefix, invoked via `turbo run name` against the workspace root, now surface in `runner list` under their bare name. Workspace-scoped entries (`pkg#task`) remain filtered, and the result set is deduplicated when both `name` and `//#name` are defined. Fixes #11. @@ -1233,7 +1282,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `site/build.ts` `publicPath` precedence: the original `env["PUBLIC_PATH"] || isCI ? X : Y` parsed as `(... || ...) ? X : Y`, so a literal `PUBLIC_PATH` value never - reached `Bun.build` — it acted as a boolean toggle. The hardcoded + reached `Bun.build`; it acted as a boolean toggle. The hardcoded `runner.kjanat.com/` fallback also leaked into Cloudflare Workers preview deploys (`*.workers.dev`) and tripped CSP `'self'`, blocking every asset on every PR preview. Replaced with @@ -1246,7 +1295,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `.github/scripts/build/package-release-asset.sh` writes checksum files as `.sha256` (not `.tar.gz.sha256`), matching `taiki-e/upload-rust-binary-action`'s convention and what - `verify-checksum.sh` enforces — the previous mismatch would have + `verify-checksum.sh` enforces; the previous mismatch would have broken the npm pipeline's checksum verification on release. - `npm/scripts/build-packages.ts`: `Target.build` union now covers all five schema enum values (previously only `cargo` | `cross`, @@ -1264,8 +1313,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic passthrough at detection time when its command body literally invokes `turbo run ` (or the shorthand `turbo `) for a same-named target, optionally followed by flag tokens - (`--filter web`, `--concurrency=4`) or — after a bare `--` - end-of-options separator (POSIX/getopt convention) — args + (`--filter web`, `--concurrency=4`) or, after a bare `--` + end-of-options separator (POSIX/getopt convention), args forwarded to the underlying task; the full bash control set (`&&`, `||`, `;`, `;;`, `;&`, `;;&`, `|`, `|&`, `&`, `!`, `{`, `}`, `(`, `)`), fd-style redirects (bare `>`/`<`/`>>`/`<<<`, @@ -1274,8 +1323,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `$X`/`${X}`/`${X:-def}`/`${X//a/b}`, special vars `$@`/`$*`/ `$#`/`$?`, command substitution `$(cmd)` and backtick `` `cmd` ``, arithmetic `$((expr))`, double-quoted forms with - embedded expansion `"${X}"`) — including those positioned after - a value-expecting flag or after `--` — and extra positional + embedded expansion `"${X}"`), including those positioned after + a value-expecting flag or after `--`, and extra positional targets all reject the match so scripts that do real work beyond dispatching to turbo stay visible. Only thin passthroughs are dropped from completion when a same-named `turbo.json` task @@ -1380,7 +1429,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `site/build.ts` `publicPath` precedence: the original `env["PUBLIC_PATH"] || isCI ? X : Y` parsed as `(... || ...) ? X : Y`, so a literal `PUBLIC_PATH` value never - reached `Bun.build` — it acted as a boolean toggle. The hardcoded + reached `Bun.build`; it acted as a boolean toggle. The hardcoded `runner.kjanat.com/` fallback also leaked into Cloudflare Workers preview deploys (`*.workers.dev`) and tripped CSP `'self'`, blocking every asset on every PR preview. Replaced with @@ -1393,7 +1442,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `.github/scripts/build/package-release-asset.sh` writes checksum files as `.sha256` (not `.tar.gz.sha256`), matching `taiki-e/upload-rust-binary-action`'s convention and what - `verify-checksum.sh` enforces — the previous mismatch would have + `verify-checksum.sh` enforces; the previous mismatch would have broken the npm pipeline's checksum verification on release. - `npm/scripts/build-packages.ts`: `Target.build` union now covers all five schema enum values (previously only `cargo` | `cross`, @@ -1411,8 +1460,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic passthrough at detection time when its command body literally invokes `turbo run ` (or the shorthand `turbo `) for a same-named target, optionally followed by flag tokens - (`--filter web`, `--concurrency=4`) or — after a bare `--` - end-of-options separator (POSIX/getopt convention) — args + (`--filter web`, `--concurrency=4`) or, after a bare `--` + end-of-options separator (POSIX/getopt convention), args forwarded to the underlying task; the full bash control set (`&&`, `||`, `;`, `;;`, `;&`, `;;&`, `|`, `|&`, `&`, `!`, `{`, `}`, `(`, `)`), fd-style redirects (bare `>`/`<`/`>>`/`<<<`, @@ -1421,8 +1470,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `$X`/`${X}`/`${X:-def}`/`${X//a/b}`, special vars `$@`/`$*`/ `$#`/`$?`, command substitution `$(cmd)` and backtick `` `cmd` ``, arithmetic `$((expr))`, double-quoted forms with - embedded expansion `"${X}"`) — including those positioned after - a value-expecting flag or after `--` — and extra positional + embedded expansion `"${X}"`), including those positioned after + a value-expecting flag or after `--`, and extra positional targets all reject the match so scripts that do real work beyond dispatching to turbo stay visible. Only thin passthroughs are dropped from completion when a same-named `turbo.json` task @@ -1534,7 +1583,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `npm.sh` validates `optionalDependencies` in `publish_allowed`: the façade must list every required platform under the scope at exactly `EXPECTED_VERSION`, and platform sub-packages must declare - none — closing a vector where a tampered platform package could + none, closing a vector where a tampered platform package could smuggle attacker-controlled transitive deps. - `npm view` and `npm publish` are wrapped in `timeout 120s` with explicit `124` handling so a hung registry cannot burn the full @@ -1562,7 +1611,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic user's prompt. The completer function now scopes `NULL_GLOB` via `emulate -L zsh -o NULL_GLOB`, so globs evaluated by `_files` internals or user zstyles (e.g. specs tagged `globbed-files`) - silently drop when they match nothing — fixing both the + silently drop when they match nothing, fixing both the `no matches found: *:globbed-files` error under the default `NOMATCH`, and the subsequent `*(/)` / `*(-/)` residue that would otherwise appear on the command line under `NO_NOMATCH` when @@ -1641,7 +1690,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic `run` alias binary. - Remove the `tool::deno::exec_cmd` and `tool::cargo_pm::exec_cmd` helpers: `deno run ` treats the target as a local script, and - `cargo ` dispatches to a cargo subcommand/plugin — neither runs + `cargo ` dispatches to a cargo subcommand/plugin; neither runs arbitrary package binaries like `npx` does. `runner run ` in a Deno- or Cargo-only project now spawns `` directly via `PATH`. @@ -1722,7 +1771,7 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - Auto-detect shell from `$SHELL` when no completion argument is given. - `description` field on `Task`, threaded from justfile doc comments and go-task `desc` fields into completion candidates. -- Tag-grouped zsh completions — candidates render under section headers +- Tag-grouped zsh completions, candidates render under section headers (e.g. `-- justfile --`, `-- Commands --`) via custom `_describe` adapter. ### Changed diff --git a/README.md b/README.md index 7310fcab..9fd9628e 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ Use the action to install runner in CI ([view on marketplace](https://github.com - run: runner install --frozen test build ``` -`runner install` is not a task — it runs the project's toolchain command(s) +`runner install` is not a task; it runs the project's toolchain command(s) (`npm ci`, `cargo fetch`, `uv sync`, …), then chains the listed tasks (`test`, then `build`) sequentially. @@ -281,7 +281,7 @@ Cargo, Make, just, Deno, uv, or some handcrafted nonsense from 2021. ## Man pages `man runner` and `man run` (plus `man runner-`) ship with every -install channel — AUR (`runner-run` / `runner-run-bin`), npm +install channel, AUR (`runner-run` / `runner-run-bin`), npm (`npm i -g runner-run`), crates.io, and `install.sh`. The pages are rendered from the CLI definition at release time, not committed. @@ -307,14 +307,14 @@ run clean run install ``` -runs a project task named `clean` or `install` when one exists — even though +runs a project task named `clean` or `install` when one exists, even though those names are also built-in `runner` subcommands. When no such task exists, a bare built-in verb (`install`, `clean`, `list`, `info`, `completions`) falls back to that built-in's default form (so `run install` installs dependencies) rather than the package-manager exec path. The explicit subcommand is the inverse: `runner install` (and `runner clean`, -`runner list`, …) is **always** the built-in and never runs a same-named task — +`runner list`, …) is **always** the built-in and never runs a same-named task; use `run install` / `runner run install` to reach a task called `install`. ## Configuration @@ -345,7 +345,7 @@ node = "pnpm" # npm | pnpm | yarn | bun | deno python = "uv" # uv | poetry | pipenv # Prefer which source runs an ambiguous task name (one that exists under more -# than one source — e.g. a package.json script AND a turbo task). Labels are +# than one source, e.g. a package.json script AND a turbo task). Labels are # task runners, package managers (bun, npm, ... map to package.json), or source # names (package.json). Rank-only: unlisted sources still run. An explicit # qualifier (package.json:test), --runner, or --pm still outranks these. @@ -353,29 +353,35 @@ python = "uv" # uv | poetry | pipenv prefer = ["turbo", "bun"] # global order: turbo, then package.json overrides = { dev = "bun", build = "turbo" } # per-task pins beat the order -# Deprecated — superseded by [tasks] above. Legacy ranked allow-list of task +# Deprecated, superseded by [tasks] above. Legacy ranked allow-list of task # runners that also *restricts* candidates (a same-named task under an unlisted # runner is rejected). Still honored for existing configs, with a warning. # [task_runner] # prefer = ["just", "turbo"] # turbo, nx, make, just, task, mise, bacon # Restrict which detected package managers `runner install` runs. Empty/absent -# installs every detected PM. In a polyglot repo where both bun and deno would -# write node_modules, this keeps install to one. Overridden by -# RUNNER_INSTALL_PMS (comma-separated). `[pm]` above only scopes script -# dispatch, not the install fan-out. +# installs every detected PM. Overridden by RUNNER_INSTALL_PMS +# (comma-separated). `[pm]` above only scopes script dispatch, not the install +# fan-out. +# `on_collision` decides what happens when two of them write the same directory +# (bun and a nodeModulesDir-enabled deno both writing node_modules). "resolve" +# (the default) installs with the PM the resolver already picked for the +# ecosystem and skips the other, saying so; naming both in `pms` runs both, one +# after another over the shared tree. "error" refuses to pick and exits 2. +# Overridden by RUNNER_INSTALL_ON_COLLISION. # `scripts` controls install-time lifecycle scripts (the main supply-chain # attack surface): "deny" skips them where the PM allows it # (npm/yarn/pnpm/bun/composer; deno already denies); "allow" forces them on # where the PM can express it (npm --no-ignore-scripts, yarn-berry -# YARN_ENABLE_SCRIPTS=true, deno --allow-scripts) — useful now that npm/pnpm are +# YARN_ENABLE_SCRIPTS=true, deno --allow-scripts), useful now that npm/pnpm are # moving to scripts-off-by-default. bun and pnpm (>=10) can't be forced on by a # flag (their dependency build scripts need a trustedDependencies / # onlyBuiltDependencies manifest allowlist runner won't write), so they warn. # Precedence: CLI --no-scripts/--scripts > RUNNER_INSTALL_SCRIPTS > [install].scripts. [install] -pms = ["bun"] # only install with these; each must be detected -scripts = "deny" # deny | allow (absent = each PM's own default) +pms = ["bun"] # only install with these; each must be detected +scripts = "deny" # deny | allow (absent = each PM's own default) +on_collision = "resolve" # resolve (one writer per install dir) | error # Resolver policy knobs. [resolution] @@ -383,7 +389,7 @@ fallback = "probe" # probe (PATH probe) | npm (legacy) | error on_mismatch = "warn" # warn | error (exit 2) | ignore (manifest vs lockfile) # Failure policy for `-s`/`-p` chains and `install `. -# keep_going and kill_on_fail are mutually exclusive — setting both is an error. +# keep_going and kill_on_fail are mutually exclusive; setting both is an error. [chain] keep_going = false # run every task despite failures (same as -k) kill_on_fail = false # parallel: kill siblings on first failure (same as -K) @@ -414,11 +420,11 @@ runner lsp # speaks LSP over stdio It provides, reusing the same logic the CLI uses: -- **diagnostics** — the exact `runner config validate` checks (syntax, unknown +- **diagnostics**, the exact `runner config validate` checks (syntax, unknown keys, bad package-manager / runner / source labels, conflicting policies) plus deprecation hints, live as you type; -- **hover** — section and field documentation, sourced from the JSON Schema; -- **completion** — section names, field names, and value sets (package managers, +- **hover**, section and field documentation, sourced from the JSON Schema; +- **completion**, section names, field names, and value sets (package managers, the `[tasks]` runner/PM/source labels, policy enums, booleans). Point your editor's generic LSP client at `runner lsp` for files named diff --git a/action.mjs b/action.mjs index 922fb43e..cdd5e817 100644 --- a/action.mjs +++ b/action.mjs @@ -12,7 +12,7 @@ import { arch, env, exit, platform, stdout } from "node:process"; */ function fileCommand(name, block) { const file = env[name]; - if (!file) throw new Error(`${name} is not set — not running inside a GitHub Action?`); + if (!file) throw new Error(`${name} is not set, not running inside a GitHub Action?`); appendFileSync(file, `${block}${EOL}`); } @@ -28,7 +28,7 @@ function addPath(dir) { function setOutput(name, value) { const delim = `ghadelimiter_${randomUUID()}`; if (name.includes(delim) || value.includes(delim)) { - throw new Error("output delimiter collision (astronomically unlikely — retry)"); + throw new Error("output delimiter collision (astronomically unlikely, retry)"); } fileCommand("GITHUB_OUTPUT", `${name}<<${delim}${EOL}${value}${EOL}${delim}`); } @@ -94,7 +94,7 @@ function withRetry(fn, backoffsMs) { if (attempt >= backoffsMs.length) throw err; const wait = backoffsMs[attempt]; const msg = err instanceof Error ? err.message : String(err); - debug(`attempt ${attempt + 1} failed (${msg}) — retrying in ${wait}ms`); + debug(`attempt ${attempt + 1} failed (${msg}), retrying in ${wait}ms`); sleep(wait); } } @@ -110,7 +110,7 @@ function resolveSpec() { const m = /^v?(\d{1,9}(?:\.\d{1,9}){0,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/ .exec(requested); if (!m) { - warn(`'${requested}' is not a semver pin or 'latest' — falling back to 'latest'`); + warn(`'${requested}' is not a semver pin or 'latest', falling back to 'latest'`); return "latest"; } console.log(`version: ${m[1]} (from '${requested}')`); @@ -120,7 +120,7 @@ function resolveSpec() { /** @returns {string} */ function installPrefix() { const toolCache = env.RUNNER_TOOL_CACHE; - if (!toolCache) throw new Error("RUNNER_TOOL_CACHE is not set — not running inside a GitHub Action?"); + if (!toolCache) throw new Error("RUNNER_TOOL_CACHE is not set, not running inside a GitHub Action?"); const prefix = join(toolCache, "runner-cli"); mkdirSync(prefix, { recursive: true }); return prefix; @@ -130,8 +130,8 @@ function installPrefix() { * Resolve the `@runner-run/` platform package matching this runner, * so install can skip the facade's optionalDependencies resolution * (13 extra registry metadata fetches for packages that'll never be used). - * Returns null on anything unexpected — unmapped platform, unreadable - * manifest, undetectable libc — so the caller falls back to the facade, + * Returns null on anything unexpected, unmapped platform, unreadable + * manifest, undetectable libc, so the caller falls back to the facade, * which covers every platform npm's own optionalDependencies resolution * covers. * @returns {{ scope: string, pkg: string } | null} @@ -142,7 +142,7 @@ function resolvePlatformTarget() { try { manifest = JSON.parse(readFileSync(join(import.meta.dirname, "npm", "targets.json"), "utf8")); } catch (err) { - debug(`could not read npm/targets.json (${err instanceof Error ? err.message : String(err)}) — using facade`); + debug(`could not read npm/targets.json (${err instanceof Error ? err.message : String(err)}), using facade`); return null; } @@ -150,7 +150,7 @@ function resolvePlatformTarget() { let libc; if (platform === "linux") { try { - // Node's own signal for glibc vs musl — the same mechanism npm's + // Node's own signal for glibc vs musl, the same mechanism npm's // optionalDependencies resolution relies on for the `libc` field. const report = /** @type {{ header?: { glibcVersionRuntime?: string } }} */ (process.report?.getReport?.()); libc = report?.header?.glibcVersionRuntime ? "glibc" : "musl"; @@ -165,7 +165,7 @@ function resolvePlatformTarget() { && (t.libc == null || (libc !== undefined && t.libc.includes(libc))) ); if (!match) { - debug(`no npm/targets.json entry for ${platform}/${arch}${libc ? `/${libc}` : ""} — using facade`); + debug(`no npm/targets.json entry for ${platform}/${arch}${libc ? `/${libc}` : ""}, using facade`); return null; } return { scope: manifest.scope, pkg: match.pkg }; @@ -224,8 +224,8 @@ try { // Installing the platform package directly skips the facade's // optionalDependencies resolution (a registry metadata fetch per - // sibling platform package that will never be used). Any failure — - // including a genuinely unpublished experimental-platform version — + // sibling platform package that will never be used). Any failure, + // including a genuinely unpublished experimental-platform version, // falls back to the facade, which is what every platform used before. let installedPkg = facade; const target = resolvePlatformTarget(); @@ -236,7 +236,7 @@ try { installedPkg = fastPkg; } catch (err) { const msg = err instanceof Error ? err.message : String(err); - warn(`fast install of ${fastPkg}@${spec} failed (${msg}) — falling back to ${facade}`); + warn(`fast install of ${fastPkg}@${spec} failed (${msg}), falling back to ${facade}`); installPackage(facade, spec, prefix); } } else { diff --git a/aur/README.md b/aur/README.md index 0686041e..979baa65 100644 --- a/aur/README.md +++ b/aur/README.md @@ -8,10 +8,10 @@ Two packages on the [AUR](https://aur.archlinux.org/): | `runner-run-bin` | prebuilt from GitHub release tars | x86_64, aarch64, armv7h | `runner-run-bin` `provides`/`conflicts` `runner-run`, so it is a drop-in -replacement — install whichever you prefer, not both. +replacement; install whichever you prefer, not both. Both packages install bash, zsh, and fish completion files for `runner` -and `run` into the system autoload dirs — no `eval` line needed in a +and `run` into the system autoload dirs, no `eval` line needed in a user's rc. The completions are clap-dynamic (the shell shells out to the binary for candidates), so tab-completing in a project picks up the *current* task list from `package.json` / `turbo.json` / `Justfile` / diff --git a/aur/runner-run-bin/PKGBUILD b/aur/runner-run-bin/PKGBUILD index 72a54146..823c030b 100644 --- a/aur/runner-run-bin/PKGBUILD +++ b/aur/runner-run-bin/PKGBUILD @@ -17,7 +17,7 @@ provides=('runner-run') conflicts=('runner-run') # Per-arch release tarballs. Basenames already carry the Rust triple, so -# each arch downloads to a distinct file — no `name::` rename needed (and +# each arch downloads to a distinct file, no `name::` rename needed (and # none with a literal arch that namcap would flag). _url="https://github.com/kjanat/runner/releases/download/v$pkgver/runner-v$pkgver" # Arch-independent man pages (one archive for all arches). @@ -42,7 +42,7 @@ package() { # `runner` binary baked in via `current_exe()`. Strategy: # 1. Generate the combined stream for each shell. # 2. sed-rewrite the baked $srcdir paths to /usr/bin/{runner,run}. - # Longer match first — `$srcdir/run` is a prefix of `$srcdir/runner`. + # Longer match first, `$srcdir/run` is a prefix of `$srcdir/runner`. # 3. awk-split bash + zsh on their start-of-line boundaries so each # command gets its own autoload file. Fish stays as one file. local g="$srcdir/_compl" @@ -62,13 +62,13 @@ package() { install -Dm0644 "$g/run.bash" "$pkgdir/usr/share/bash-completion/completions/run" install -Dm0644 "$g/_runner" "$pkgdir/usr/share/zsh/site-functions/_runner" install -Dm0644 "$g/_run" "$pkgdir/usr/share/zsh/site-functions/_run" - # Fish autoloads completion files by command basename — `runner.fish` is + # Fish autoloads completion files by command basename; `runner.fish` is # sourced on `runner` but never on `run`. Install the (identical) # combined stream under both names so each command's first tab works in # a fresh shell, without depending on session order. install -Dm0644 "$g/fish.combined" "$pkgdir/usr/share/fish/vendor_completions.d/runner.fish" install -Dm0644 "$g/fish.combined" "$pkgdir/usr/share/fish/vendor_completions.d/run.fish" - # PowerShell has no system autoload dir on Linux — pwsh users dot-source + # PowerShell has no system autoload dir on Linux; pwsh users dot-source # this file from their `$PROFILE`: . /usr/share/runner/runner.ps1 install -Dm0644 "$g/runner.ps1" "$pkgdir/usr/share/runner/runner.ps1" diff --git a/aur/runner-run/PKGBUILD b/aur/runner-run/PKGBUILD index 2d707e83..73217a66 100644 --- a/aur/runner-run/PKGBUILD +++ b/aur/runner-run/PKGBUILD @@ -50,7 +50,7 @@ package() { # `runner` binary baked in via `current_exe()`. Strategy: # 1. Generate the combined stream for each shell. # 2. sed-rewrite the baked target/release paths to /usr/bin/{runner,run}. - # Longer match first — `…/run` is a prefix of `…/runner`. + # Longer match first, `…/run` is a prefix of `…/runner`. # 3. awk-split bash + zsh on their start-of-line boundaries so each # command gets its own autoload file. Fish stays as one file. local g="$srcdir/_compl" @@ -72,13 +72,13 @@ package() { install -Dm0644 "$g/run.bash" "$pkgdir/usr/share/bash-completion/completions/run" install -Dm0644 "$g/_runner" "$pkgdir/usr/share/zsh/site-functions/_runner" install -Dm0644 "$g/_run" "$pkgdir/usr/share/zsh/site-functions/_run" - # Fish autoloads completion files by command basename — `runner.fish` is + # Fish autoloads completion files by command basename; `runner.fish` is # sourced on `runner` but never on `run`. Install the (identical) # combined stream under both names so each command's first tab works in # a fresh shell, without depending on session order. install -Dm0644 "$g/fish.combined" "$pkgdir/usr/share/fish/vendor_completions.d/runner.fish" install -Dm0644 "$g/fish.combined" "$pkgdir/usr/share/fish/vendor_completions.d/run.fish" - # PowerShell has no system autoload dir on Linux — pwsh users dot-source + # PowerShell has no system autoload dir on Linux; pwsh users dot-source # this file from their `$PROFILE`: . /usr/share/runner/runner.ps1 install -Dm0644 "$g/runner.ps1" "$pkgdir/usr/share/runner/runner.ps1" diff --git a/install.sh b/install.sh index c146f687..ce2dfa92 100755 --- a/install.sh +++ b/install.sh @@ -70,7 +70,7 @@ resolve_target() { # These predicates print "yes"/"no" rather than returning an exit status: # callers invoke them via command substitution and test the printed string. -# That keeps them composable under `set -e` without ShellCheck SC2310 — a +# That keeps them composable under `set -e` without ShellCheck SC2310; a # function used directly as a condition silently disables set -e inside it. dir_on_path() { case ":${PATH:-}:" in diff --git a/justfile b/justfile index dbadc63e..f03c728f 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,7 @@ # https://just.systems set unstable -# Version/triple live in recipe parameter defaults (evaluated per invocation), not globals — just evaluates globals on every run. +# Version/triple live in recipe parameter defaults (evaluated per invocation), not globals; just evaluates globals on every run. build-pkgscript := "npm" / "scripts" / "build-packages.ts" downloads-dir := "npm" / "downloads" diff --git a/npm/facade/lib/launch.cjs b/npm/facade/lib/launch.cjs index 8c6b06a4..536e2ec7 100644 --- a/npm/facade/lib/launch.cjs +++ b/npm/facade/lib/launch.cjs @@ -15,7 +15,7 @@ module.exports = function launch(name) { if (result.error) throw result.error; // Child died from a signal (SIGINT, SIGTERM, …). // Re-raise it on ourselves so the parent shell sees `WIFSIGNALED` / exit - - // code 128 + N instead of a generic 1 — `set -e`, trap handlers, + // code 128 + N instead of a generic 1; `set -e`, trap handlers, // and Ctrl+C chaining all depend on this. if (result.signal) { process.removeAllListeners(result.signal); diff --git a/npm/facade/lib/resolve.cjs b/npm/facade/lib/resolve.cjs index c07840b7..53b650da 100644 --- a/npm/facade/lib/resolve.cjs +++ b/npm/facade/lib/resolve.cjs @@ -63,7 +63,7 @@ This usually means your package manager skipped ${blueText}optionalDependencies$ Workarounds: ${indent}- reinstall without: ${blueText}--no-optional${reset} / ${blueText}--omit=optional${reset} -${indent}- bun + ${blueText}minimumReleaseAge${reset}: add the ${blueText}@runner-run/*${reset} platform packages (not just ${blueText}${pkgName}${reset}) to ${blueText}minimumReleaseAgeExcludes${reset} — a fresh release is otherwise age-gated +${indent}- bun + ${blueText}minimumReleaseAge${reset}: add the ${blueText}@runner-run/*${reset} platform packages (not just ${blueText}${pkgName}${reset}) to ${blueText}minimumReleaseAgeExcludes${reset}; a fresh release is otherwise age-gated ${indent}- install from source: ${blueText}cargo install --git=${repo}/ runner${reset} ${indent}- file an issue if your platform is unsupported: ${osc8(`${repo}/issues`)}${detail} `; diff --git a/npm/scripts/build-packages.ts b/npm/scripts/build-packages.ts index b078e82a..100e6886 100644 --- a/npm/scripts/build-packages.ts +++ b/npm/scripts/build-packages.ts @@ -10,7 +10,7 @@ * `GITHUB_ACTIONS=true`), a dev machine only ever has native binaries for its * own host, so a bare local run: builds the host's own tarball with * `cargo bbr` if it's missing, and treats every other target's missing - * tarball as skippable (same as `--skip-missing`) instead of failing — a + * tarball as skippable (same as `--skip-missing`) instead of failing; a * plain `build-packages` "just works" for whatever platform you're on. * * Usage: @@ -42,7 +42,7 @@ const FACADE_BIN_FILES = ["runner.cjs", "run.cjs"] as const; const FACADE_LIB_FILES = ["resolve.cjs", "launch.cjs"] as const; // npm allows multiple shapes for several `package.json` fields. Cargo's -// `package.metadata` is user-defined freeform JSON — the user could put any +// `package.metadata` is user-defined freeform JSON; the user could put any // of these shapes (or a typo, or a number) under `metadata.npm.repository` // and Cargo wouldn't care. We narrow at parse time so a malformed manifest // fails the build with a useful pointer instead of producing a garbage @@ -59,7 +59,7 @@ interface CargoManifest { // Top-level Cargo fields. Both are always strings when Cargo emits them. homepage?: string; repository?: string; - // Untyped on purpose — every access goes through a narrowing helper. + // Untyped on purpose, every access goes through a narrowing helper. metadata?: unknown; } @@ -224,7 +224,7 @@ function readCargoManifest(): CargoManifest { : []; // Single workspace member: that's the package. Multi-member workspace: // match the first default member by id (Cargo's own "what does a bare - // `cargo build` build" answer) — falls back to the first package so a + // `cargo build` build" answer). Falls back to the first package so a // non-virtual workspace without explicit defaults still resolves. const pickById = defaults[0] ? envelope.packages.find((p): p is Record => isObject(p) && p.id === defaults[0]) @@ -549,7 +549,7 @@ async function buildFacade( // Cargo metadata wins over the template for fields it owns (license, // author, homepage, repository, bugs, engines). Keeps the facade and - // sub-packages in lockstep on engines/runtime contract — drop those + // sub-packages in lockstep on engines/runtime contract; drop those // fields from the template so there's only one source of truth. const files = Array.isArray(template.files) ? [...template.files] : []; if (manFiles.length > 0 && !files.includes("man/")) files.push("man/"); @@ -829,7 +829,7 @@ platform, so prefer \`${matrix.facade}\` for anything portable. ## Standalone use -The package is a working CLI in its own right — its bins point straight at the bundled +The package is a working CLI in its own right; its bins point straight at the bundled ${binaries} binaries, so on a matching machine it runs without the facade: \`\`\`sh diff --git a/npm/targets.schema.json b/npm/targets.schema.json index 631dbc57..951fc8c5 100644 --- a/npm/targets.schema.json +++ b/npm/targets.schema.json @@ -18,7 +18,7 @@ "properties": { "build": { "description": "- \"cargo\" for native compilation on a matching runner; \n- \"cross\" for cross-compilation via the \"cross\" crate (typically Linux → { Linux, *BSD }); \n- \"cargo-cross-toolchain\" for stable cross-compilation via taiki-e/setup-cross-toolchain-action (Linux → tier-2 BSD/illumos with prebuilt std); \n- \"cargo-build-std\" for tier-3 cross-compilation via setup-cross-toolchain-action + nightly + -Z build-std (Linux → tier-3 BSD without prebuilt std); \n- \"vm\" for builds that run inside a target-OS VM (e.g. OpenBSD on a vmactions/openbsd-vm sidecar) and bypass the standard upload-assets matrix.", - "markdownDescription": "# Build tool \n- `cargo` — native compilation on a matching runner. \n- `cross` — cross-compilation via the `cross` crate (Linux → { Linux, *BSD } where cross has a maintained image). \n- `cargo-cross-toolchain` — cross-compilation via [`taiki-e/setup-cross-toolchain-action`](https://github.com/taiki-e/setup-cross-toolchain-action) on a Linux runner; uses host `cargo` with a real cross C toolchain. For tier-2 BSD/illumos targets where `std` is prebuilt. \n- `cargo-build-std` — tier-3 cross-compilation: `setup-cross-toolchain-action` + nightly Rust + `-Z build-std`. The release workflow handles the manual build/package/upload because `taiki-e/upload-rust-binary-action` cannot inject `-Z build-std`. \n- `vm` — built inside a target-OS VM (e.g. OpenBSD via [`vmactions/openbsd-vm`](https://github.com/vmactions/openbsd-vm)). Bypasses the matrix-driven upload-assets job; handled by a dedicated workflow job.", + "markdownDescription": "# Build tool \n- `cargo`, native compilation on a matching runner. \n- `cross`, cross-compilation via the `cross` crate (Linux → { Linux, *BSD } where cross has a maintained image). \n- `cargo-cross-toolchain`, cross-compilation via [`taiki-e/setup-cross-toolchain-action`](https://github.com/taiki-e/setup-cross-toolchain-action) on a Linux runner; uses host `cargo` with a real cross C toolchain. For tier-2 BSD/illumos targets where `std` is prebuilt. \n- `cargo-build-std`, tier-3 cross-compilation: `setup-cross-toolchain-action` + nightly Rust + `-Z build-std`. The release workflow handles the manual build/package/upload because `taiki-e/upload-rust-binary-action` cannot inject `-Z build-std`. \n- `vm`, built inside a target-OS VM (e.g. OpenBSD via [`vmactions/openbsd-vm`](https://github.com/vmactions/openbsd-vm)). Bypasses the matrix-driven upload-assets job; handled by a dedicated workflow job.", "type": "string", "enum": [ "cargo", @@ -29,8 +29,8 @@ ] }, "cpu": { - "description": "npm \"cpu\" field — values from Node's \"process.arch\".", - "markdownDescription": "# CPU architecture \n[npm `cpu` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#cpu) — values from Node's [`process.arch`](https://nodejs.org/api/process.html#processarch).", + "description": "npm \"cpu\" field, values from Node's \"process.arch\".", + "markdownDescription": "# CPU architecture \n[npm `cpu` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#cpu), values from Node's [`process.arch`](https://nodejs.org/api/process.html#processarch).", "type": "array", "minItems": 1, "uniqueItems": true, @@ -58,8 +58,8 @@ "type": "boolean" }, "libc": { - "description": "npm \"libc\" field — only meaningful on Linux. \nDistinguishes glibc and musl builds so the right sub-package is installed on Alpine etc.", - "markdownDescription": "# libc variant \n[npm `libc` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#libc) — only meaningful on Linux. \nDistinguishes glibc and musl builds so the right sub-package is installed on Alpine etc.", + "description": "npm \"libc\" field, only meaningful on Linux. \nDistinguishes glibc and musl builds so the right sub-package is installed on Alpine etc.", + "markdownDescription": "# libc variant \n[npm `libc` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#libc), only meaningful on Linux. \nDistinguishes glibc and musl builds so the right sub-package is installed on Alpine etc.", "type": "array", "minItems": 1, "uniqueItems": true, @@ -72,8 +72,8 @@ } }, "os": { - "description": "npm \"os\" field — values from Node's \"process.platform\". \nnpm uses this to select the correct \"optionalDependency\" at install time.", - "markdownDescription": "# Operating system \n[npm `os` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#os) — values from Node's [`process.platform`](https://nodejs.org/api/process.html#processplatform). \nUsed to select the correct [`optionalDependency`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#optionaldependencies) at install time.", + "description": "npm \"os\" field, values from Node's \"process.platform\". \nnpm uses this to select the correct \"optionalDependency\" at install time.", + "markdownDescription": "# Operating system \n[npm `os` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#os), values from Node's [`process.platform`](https://nodejs.org/api/process.html#processplatform). \nUsed to select the correct [`optionalDependency`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#optionaldependencies) at install time.", "type": "array", "minItems": 1, "uniqueItems": true, diff --git a/rust-toolchain.toml b/rust-toolchain.toml index f4f1cab8..e3a682ee 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "stable" +channel = "nightly-2026-07-15" components = ["rustfmt", "clippy", "rust-analyzer"] profile = "minimal" diff --git a/schemas/doctor.example.json b/schemas/doctor.example.json index 16ee113c..711c57bc 100644 --- a/schemas/doctor.example.json +++ b/schemas/doctor.example.json @@ -1,6 +1,6 @@ { "$schema": "https://kjanat.github.io/runner/schemas/doctor.schema.json", - "schema_version": 1, + "schema_version": 2, "kind": "runner.doctor", "invocation": { "argv": [ @@ -25,9 +25,9 @@ "name": "runner", "version": "0.19.1", "schema_versions": { - "doctor": 1, - "list": 1, - "why": 1 + "doctor": 2, + "list": 2, + "why": 2 } }, "project": { diff --git a/schemas/doctor.schema.json b/schemas/doctor.schema.json index f532f33c..f02f5b96 100644 --- a/schemas/doctor.schema.json +++ b/schemas/doctor.schema.json @@ -2,6 +2,21 @@ "$id": "https://kjanat.github.io/runner/schemas/doctor.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { + "CollisionPolicy": { + "description": "How `runner install` reacts when two or more package managers in the\ninstall set write the same directory (a node PM plus a\n`nodeModulesDir`-enabled Deno both materializing `node_modules/`).\n\nSet via `[install].on_collision` / `RUNNER_INSTALL_ON_COLLISION`.", + "oneOf": [ + { + "description": "Install with one writer per directory and shadow the rest, the same\nway a duplicate task name resolves to one source. An explicit\n`[install].pms` naming several writers is consent: they all run,\nserialized over the shared tree.", + "type": "string", + "const": "resolve" + }, + { + "description": "Refuse to install and exit non-zero rather than pick. For CI\nguardrails that want an ambiguous tree to block the run.", + "type": "string", + "const": "error" + } + ] + }, "Confidence": { "description": "How sure the resolver is about an ecosystem's PM selection.", "oneOf": [ @@ -28,41 +43,89 @@ ] }, "Conflict": { - "description": "A task name claimed by more than one source: who wins, who is shadowed.", - "type": "object", - "required": [ - "kind", - "reason", - "selected", - "selector", - "severity", - "shadowed" - ], - "properties": { - "kind": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "selected": { - "description": "FQN of the winning task.", - "type": "string" - }, - "selector": { - "type": "string" - }, - "severity": { - "$ref": "#/$defs/Severity" + "description": "A task-name or install-directory conflict, tagged by `kind`.", + "oneOf": [ + { + "description": "A task name claimed by more than one source: which task wins and which\nfully-qualified task names are shadowed.", + "type": "object", + "required": [ + "kind", + "reason", + "selected", + "selector", + "severity", + "shadowed" + ], + "properties": { + "kind": { + "type": "string", + "const": "duplicate-task-name" + }, + "reason": { + "type": "string" + }, + "selected": { + "description": "FQN of the winning task.", + "type": "string" + }, + "selector": { + "description": "Conflicting task name.", + "type": "string" + }, + "severity": { + "$ref": "#/$defs/Severity" + }, + "shadowed": { + "description": "FQNs of the shadowed tasks.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false }, - "shadowed": { - "type": "array", - "items": { - "type": "string" - } + { + "description": "Package managers that write the same installation directory: which\npackage manager installs it and which package managers are shadowed.", + "type": "object", + "required": [ + "kind", + "reason", + "selected", + "selector", + "severity", + "shadowed" + ], + "properties": { + "kind": { + "type": "string", + "const": "install-dir-collision" + }, + "reason": { + "type": "string" + }, + "selected": { + "description": "Label of the selected package manager.", + "type": "string" + }, + "selector": { + "description": "Path of the conflicting installation directory.", + "type": "string" + }, + "severity": { + "$ref": "#/$defs/Severity" + }, + "shadowed": { + "description": "Labels of the shadowed package managers.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false } - }, - "additionalProperties": false + ] }, "DependencyKind": { "description": "What kind of thing a probed tool is. The draft's `binary` /\n`package-binary` kinds join when something probes them.", @@ -276,7 +339,7 @@ "additionalProperties": false }, "FailurePolicy": { - "description": "Failure policy for a chain. `FailFast` is the default and matches\n`make -j` semantics in parallel mode (let running siblings finish,\ndon't start new ones).", + "description": "Failure policy for a chain. `FailFast` is the default and matches\n`make -j` semantics in parallel mode (let running siblings finish;\ndon't start new ones).", "oneOf": [ { "description": "Stop the chain on the first failing task. In parallel mode,\nalready-running siblings complete naturally.", @@ -289,7 +352,7 @@ "const": "keep-going" }, { - "description": "Parallel only: SIGKILL siblings on first failure (`std::process::Child::kill`).\nSequential callers accept this silently (no-op). Catch-able SIGTERM\nsemantics would need a libc/nix dep — deferred to a follow-up.", + "description": "Parallel only: SIGKILL siblings on first failure (`std::process::Child::kill`).\nSequential callers accept this silently (no-op). Catch-able SIGTERM\nsemantics would need a libc/nix dep, deferred to a follow-up.", "type": "string", "const": "kill-on-fail" } @@ -341,10 +404,10 @@ "additionalProperties": false }, "MismatchPolicy": { - "description": "How to react when manifest declaration (step 5) and lockfile (step 6)\ndisagree about which package manager the project uses.\n\nSet via `--on-mismatch` / `RUNNER_ON_MISMATCH` /\n`[resolution].on_mismatch`. Independent from\n`devEngines.packageManager` `onFail` — that policy governs whether\nthe *declared* PM can actually run; this one governs whether the\nresolver tolerates the declaration disagreeing with the install\nstate at all.", + "description": "How to react when manifest declaration (step 5) and lockfile (step 6)\ndisagree about which package manager the project uses.\n\nSet via `--on-mismatch` / `RUNNER_ON_MISMATCH` /\n`[resolution].on_mismatch`. Independent from\n`devEngines.packageManager` `onFail`. That policy governs whether\nthe *declared* PM can actually run; this one governs whether the\nresolver tolerates the declaration disagreeing with the install\nstate at all.", "oneOf": [ { - "description": "Emit a `package.json` warning, prefer the declaration (Corepack\nsemantics — the lockfile is most likely stale).", + "description": "Emit a `package.json` warning; prefer the declaration (Corepack\nsemantics, the lockfile is most likely stale).", "type": "string", "const": "warn" }, @@ -354,14 +417,14 @@ "const": "ignore" }, { - "description": "Bail with [`super::ResolveError::MismatchPolicyError`]. Intended for\nCI guardrails where a mismatch should block the run.", + "description": "Refuse to run and exit non-zero. Intended for CI guardrails where a\nmismatch should block the run.", "type": "string", "const": "error" } ] }, "OutputGrouping": { - "description": "The three grouping toggles bundled so [`Overrides`] doesn't tip\nclippy's bool-count lint; each mirrors a same-named field on\n[`ResolutionOverrides`].", + "description": "Whether task output is grouped into collapsible blocks, under GitHub Actions and elsewhere.", "type": "object", "required": [ "github_group_parallel", @@ -385,7 +448,7 @@ "additionalProperties": false }, "Overrides": { - "description": "Effective override stack, labels only. Provenance (cli/env/config) stays on the flat `list`/`info` surface.\n\nCovers every field on [`ResolutionOverrides`] except `parent_group_open`, which is internal\nrunner-to-runner plumbing (an inherited env marker, never a user override) and has nothing\nmeaningful to report — see the drift guard test at the bottom of this file, which enforces that\na future field can't land on `ResolutionOverrides` and silently miss both this struct and its\nexclusion list.", + "description": "The overrides in effect for this run: `--pm`, `--fallback`, the\n`RUNNER_*` env vars, and the `runner.toml` policy sections, reported by\ntheir labels. Where each came from (CLI, env, or config) is on the\n`list`/`info` surface instead.", "type": "object", "required": [ "explain", @@ -393,6 +456,7 @@ "fallback", "install_pms", "no_warnings", + "on_collision", "on_mismatch", "output_grouping", "pm", @@ -423,6 +487,9 @@ "no_warnings": { "type": "boolean" }, + "on_collision": { + "$ref": "#/$defs/CollisionPolicy" + }, "on_mismatch": { "$ref": "#/$defs/MismatchPolicy" }, @@ -510,62 +577,62 @@ "description": "A dependency manager detected via lockfile or config presence.", "oneOf": [ { - "description": "npm — detected via `package-lock.json`.", + "description": "npm, detected via `package-lock.json`.", "type": "string", "const": "npm" }, { - "description": "Yarn — detected via `yarn.lock`.", + "description": "Yarn, detected via `yarn.lock`.", "type": "string", "const": "yarn" }, { - "description": "pnpm — detected via `pnpm-lock.yaml`.", + "description": "pnpm, detected via `pnpm-lock.yaml`.", "type": "string", "const": "pnpm" }, { - "description": "Bun — detected via `bun.lockb` or `bun.lock`.", + "description": "Bun, detected via `bun.lockb` or `bun.lock`.", "type": "string", "const": "bun" }, { - "description": "Cargo (Rust) — detected via `Cargo.toml`.", + "description": "Cargo (Rust), detected via `Cargo.toml`.", "type": "string", "const": "cargo" }, { - "description": "Deno — detected via `deno.json` / `deno.jsonc`.", + "description": "Deno, detected via `deno.json` / `deno.jsonc`.", "type": "string", "const": "deno" }, { - "description": "uv (Python) — detected via `uv.lock`.", + "description": "uv (Python), detected via `uv.lock`.", "type": "string", "const": "uv" }, { - "description": "Poetry (Python) — detected via `poetry.lock` or Poetry `pyproject.toml` markers.", + "description": "Poetry (Python), detected via `poetry.lock` or Poetry `pyproject.toml` markers.", "type": "string", "const": "poetry" }, { - "description": "Pipenv (Python) — detected via `Pipfile` / `Pipfile.lock`.", + "description": "Pipenv (Python), detected via `Pipfile` / `Pipfile.lock`.", "type": "string", "const": "pipenv" }, { - "description": "Go modules — detected via `go.mod`.", + "description": "Go modules, detected via `go.mod`.", "type": "string", "const": "go" }, { - "description": "Bundler (Ruby) — detected via `Gemfile`.", + "description": "Bundler (Ruby), detected via `Gemfile`.", "type": "string", "const": "bundler" }, { - "description": "Composer (PHP) — detected via `composer.json`.", + "description": "Composer (PHP), detected via `composer.json`.", "type": "string", "const": "composer" } @@ -674,7 +741,7 @@ "additionalProperties": false }, "ScriptPolicy": { - "description": "Install-time lifecycle-script execution policy for `runner install`.\n\nLifecycle/build scripts (`postinstall`, native-extension compilation,\n…) are the primary supply-chain attack surface during dependency\ninstalls. This knob lets a project deny them across the package managers\nthat expose a skip mechanism, or force them on across the managers that\ncan express it — the latter matters because several package managers\n(npm, pnpm, …) are moving to scripts-off-by-default in upcoming majors.\n\nSet via `--no-scripts` (deny) / `--scripts` (force on) on the CLI,\n`RUNNER_INSTALL_SCRIPTS` (env), or `[install].scripts` (config), highest\nfirst.", + "description": "Install-time lifecycle-script execution policy for `runner install`.\n\nLifecycle/build scripts (`postinstall`, native-extension compilation,\n…) are the primary supply-chain attack surface during dependency\ninstalls. This knob lets a project deny them across the package managers\nthat expose a skip mechanism, or force them on across the managers that\ncan express it. The latter matters because several package managers\n(npm, pnpm, …) are moving to scripts-off-by-default in upcoming majors.\n\nSet via `--no-scripts` (deny) / `--scripts` (force on) on the CLI,\n`RUNNER_INSTALL_SCRIPTS` (env), or `[install].scripts` (config), highest\nfirst.", "oneOf": [ { "description": "Leave each package manager at its own built-in default: npm,\nyarn-classic, pnpm (<10) and composer run dependency scripts, while\nbun, pnpm (>=10) and deno already deny them.", @@ -752,37 +819,37 @@ "description": "A task runner detected via config file presence.", "oneOf": [ { - "description": "Turborepo — detected via `turbo.json` / `turbo.jsonc`.", + "description": "Turborepo, detected via `turbo.json` / `turbo.jsonc`.", "type": "string", "const": "turbo" }, { - "description": "Nx — detected via `nx.json`.", + "description": "Nx, detected via `nx.json`.", "type": "string", "const": "nx" }, { - "description": "GNU Make — detected via `Makefile` / `GNUmakefile` / `makefile`.", + "description": "GNU Make, detected via `Makefile` / `GNUmakefile` / `makefile`.", "type": "string", "const": "make" }, { - "description": "just — detected via case-insensitive `justfile` / `.justfile`.", + "description": "just, detected via case-insensitive `justfile` / `.justfile`.", "type": "string", "const": "just" }, { - "description": "go-task — detected via `Taskfile.yml` and variants. Serializes as\n`\"task\"` (matching [`Self::label`]) — `kebab-case` alone would\nproduce `\"go-task\"`, the accepted parse *alias*, not the canonical\nlabel.", + "description": "go-task, detected via `Taskfile.yml` and variants.", "type": "string", "const": "task" }, { - "description": "mise — detected via `mise.toml` / `.mise.toml`.", + "description": "mise, detected via `mise.toml` / `.mise.toml`.", "type": "string", "const": "mise" }, { - "description": "bacon — detected via `bacon.toml`.", + "description": "bacon, detected via `bacon.toml`.", "type": "string", "const": "bacon" } @@ -946,7 +1013,7 @@ "schema_version": { "description": "Schema contract version for this JSON payload.", "type": "integer", - "const": 1, + "const": 2, "minimum": 0, "format": "uint32" }, diff --git a/schemas/list.example.json b/schemas/list.example.json index cafe29d9..8d37b562 100644 --- a/schemas/list.example.json +++ b/schemas/list.example.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "root": "/path/to/project", "tasks": [ { diff --git a/schemas/list.schema.json b/schemas/list.schema.json index 58257258..2e83653c 100644 --- a/schemas/list.schema.json +++ b/schemas/list.schema.json @@ -77,7 +77,7 @@ "schema_version": { "description": "Schema contract version for this JSON payload.", "type": "integer", - "const": 1, + "const": 2, "minimum": 0, "format": "uint32" }, diff --git a/schemas/runner.init.toml b/schemas/runner.init.toml index edb9d47f..69a3eb04 100644 --- a/schemas/runner.init.toml +++ b/schemas/runner.init.toml @@ -1,6 +1,6 @@ #:schema ./runner.toml.schema.json -# runner.toml — project task-runner configuration. +# runner.toml, project task-runner configuration. # Docs: https://runner.kjanat.dev # # Every key below is commented out, showing either its built-in default or an @@ -8,12 +8,12 @@ # Precedence, highest first: # CLI flags > RUNNER_* env vars > this file > manifest declarations. -# `[pm]` section — per-ecosystem package manager overrides. +# `[pm]` section, per-ecosystem package manager overrides. [pm] # node = "pnpm" # npm | yarn | pnpm | bun | deno # python = "uv" # uv | poetry | pipenv -# `[tasks]` section — persistent task-source preference for ambiguous task +# `[tasks]` section, persistent task-source preference for ambiguous task # names (a name that exists under more than one source, e.g. a `package.json` # script *and* a `turbo` task). # @@ -21,14 +21,14 @@ # (`turbo`, `make`, …), a package manager (`bun`, `npm`, `pnpm`, `yarn`, # `deno`, …), or a source name (`package.json`, `deno`, …). Package-manager # labels map to the script source they run (`bun` → `package.json`). -# Selection here is **rank-only**: it never hard-rejects an unlisted source, +# Selection here is **rank-only**: it never hard-rejects an unlisted source; # it only reorders. An explicit CLI qualifier (`package.json:test`), # `--runner`, or `--pm`/`RUNNER_PM` still outranks these file settings. [tasks] # prefer = ["turbo", "bun"] # global order: turbo, then package.json (bun) # overrides = { dev = "bun", build = "turbo" } # per-task pins beat the order -# `[install]` section — restrict which detected package managers +# `[install]` section, restrict which detected package managers # `runner install` runs with. Absent or empty installs every detected # PM (the default). Overridden by `RUNNER_INSTALL_PMS`. # @@ -38,19 +38,20 @@ [install] # pms = ["bun"] # only install with these; each must be detected # scripts = "deny" # deny | allow (absent = each PM's own default) +# on_collision = "resolve" # resolve (one writer per install dir, rest shadowed) | error (refuse to pick) -# `[resolution]` section — resolver policy knobs. +# `[resolution]` section, resolver policy knobs. [resolution] # fallback = "probe" # probe (PATH probe) | npm (legacy) | error # on_mismatch = "warn" # warn | ignore | error (exit 2) -# `[chain]` section — failure policy for `run -s/-p` chains and +# `[chain]` section, failure policy for `run -s/-p` chains and # `runner install `. [chain] # keep_going = false # run every task despite failures (same as -k) # kill_on_fail = false # parallel: kill siblings on first failure (same as -K) -# `[github]` section — GitHub Actions integration. Both knobs only take +# `[github]` section, GitHub Actions integration. Both knobs only take # effect under GitHub Actions (gated at the call site by # `actions_rs::env::is_github_actions`); in a normal terminal nothing here # changes behavior. @@ -58,7 +59,7 @@ # group_output = true # wrap each task's output in a collapsible ::group:: # group_parallel = true # buffer parallel tasks, print each as one block -# `[parallel]` section — how parallel (`-p`) chains present their output +# `[parallel]` section, how parallel (`-p`) chains present their output # **outside** GitHub Actions. (Under GitHub Actions, see # `[github].group_parallel` instead.) [parallel] diff --git a/schemas/runner.toml.schema.json b/schemas/runner.toml.schema.json index a16607d2..cedc4b70 100644 --- a/schemas/runner.toml.schema.json +++ b/schemas/runner.toml.schema.json @@ -3,7 +3,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "ChainSection": { - "description": "`[chain]` section — failure policy for `run -s/-p` chains and\n`runner install `.", + "description": "`[chain]` section, failure policy for `run -s/-p` chains and\n`runner install `.", "type": "object", "properties": { "keep_going": { @@ -14,7 +14,7 @@ ] }, "kill_on_fail": { - "description": "Parallel only: terminate sibling tasks immediately on first\nfailure (forcible kill, not graceful shutdown — uncatchable on\nUnix). Mutually exclusive with `keep_going`. Equivalent to\n`--kill-on-fail` / `RUNNER_KILL_ON_FAIL`. Ignored in sequential\ncontexts.", + "description": "Parallel only: terminate sibling tasks immediately on first\nfailure (forcible kill, not graceful shutdown, uncatchable on\nUnix). Mutually exclusive with `keep_going`. Equivalent to\n`--kill-on-fail` / `RUNNER_KILL_ON_FAIL`. Ignored in sequential\ncontexts.", "type": [ "boolean", "null" @@ -38,7 +38,7 @@ } }, "GitHubSection": { - "description": "`[github]` section — GitHub Actions integration. Both knobs only take\neffect under GitHub Actions (gated at the call site by\n`actions_rs::env::is_github_actions`); in a normal terminal nothing here\nchanges behavior.", + "description": "`[github]` section, GitHub Actions integration. Both knobs only take\neffect under GitHub Actions (gated at the call site by\n`actions_rs::env::is_github_actions`); in a normal terminal nothing here\nchanges behavior.", "type": "object", "properties": { "group_output": { @@ -47,7 +47,7 @@ "type": "boolean" }, "group_parallel": { - "description": "Under GitHub Actions, group parallel (`-p`) output: buffer each task\nand print it as one block on completion instead of interleaving lines\nlive. Defaults to `true` (CI logs read better grouped), but only when\n[`Self::group_output`] is also true. The non-CI equivalent is\n`[parallel].grouped` (default `false`), so CI and local diverge unless\nyou set them to match.", + "description": "Under GitHub Actions, group parallel (`-p`) output: buffer each task and print it as one block on completion instead of interleaving lines live. Defaults to `true`, but only when `group_output` is also true. The non-CI equivalent is `[parallel].grouped` (default `false`).", "default": true, "type": "boolean" } @@ -55,9 +55,21 @@ "additionalProperties": false }, "InstallSection": { - "description": "`[install]` section — restrict which detected package managers\n`runner install` runs with. Absent or empty installs every detected\nPM (the default). Overridden by `RUNNER_INSTALL_PMS`.\n\nUnlike `[pm]` (which scopes *script dispatch* per ecosystem), this\nscopes the *install fan-out*: in a polyglot repo where both `bun` and\n`deno` would write `node_modules`, `pms = [\"bun\"]` keeps install to bun.", + "description": "`[install]` section, restrict which detected package managers\n`runner install` runs with. Absent or empty installs every detected\nPM (the default). Overridden by `RUNNER_INSTALL_PMS`.\n\nUnlike `[pm]` (which scopes *script dispatch* per ecosystem), this\nscopes the *install fan-out*: in a polyglot repo where both `bun` and\n`deno` would write `node_modules`, `pms = [\"bun\"]` keeps install to bun.", "type": "object", "properties": { + "on_collision": { + "description": "What to do when two or more package managers in the install set write\nthe same directory (a node PM plus a `nodeModulesDir`-enabled Deno both\nmaterializing `node_modules/`). `\"resolve\"` (the default) installs with\none writer per directory and shadows the rest, the way a duplicate task\nname resolves to one source; listing several writers in `pms` is consent\nand runs them all, serialized over the shared tree. `\"error\"` refuses to\npick and fails instead. Overridden by `RUNNER_INSTALL_ON_COLLISION`.", + "type": [ + "null", + "string" + ], + "enum": [ + "resolve", + "error", + null + ] + }, "pms": { "description": "Allowlist of package-manager labels to install with, e.g.\n`[\"bun\"]`. Each must be a detected PM or `runner install` errors.\nEmpty = install with every detected PM.", "type": "array", @@ -81,11 +93,11 @@ "additionalProperties": false }, "ParallelSection": { - "description": "`[parallel]` section — how parallel (`-p`) chains present their output\n**outside** GitHub Actions. (Under GitHub Actions, see\n`[github].group_parallel` instead.)", + "description": "`[parallel]` section, how parallel (`-p`) chains present their output\n**outside** GitHub Actions. (Under GitHub Actions, see\n`[github].group_parallel` instead.)", "type": "object", "properties": { "grouped": { - "description": "Buffer each parallel task's output and print it as one contiguous\nblock the moment that task finishes (completion order — first done,\nfirst shown), instead of interleaving prefixed lines live. Defaults to\n`false` (the live `[task]`-prefixed muxer); set `true` to group even in\na plain terminal, where a colored header delimits each block.", + "description": "Buffer each parallel task's output and print it as one contiguous\nblock the moment that task finishes (completion order, first done,\nfirst shown), instead of interleaving prefixed lines live. Defaults to\n`false` (the live `[task]`-prefixed muxer); set `true` to group even in\na plain terminal, where a colored header delimits each block.", "default": false, "type": "boolean" } @@ -93,7 +105,7 @@ "additionalProperties": false }, "PmSection": { - "description": "`[pm]` section — per-ecosystem package manager overrides.", + "description": "`[pm]` section, per-ecosystem package manager overrides.", "type": "object", "properties": { "node": { @@ -128,11 +140,11 @@ "additionalProperties": false }, "ResolutionSection": { - "description": "`[resolution]` section — resolver policy knobs.", + "description": "`[resolution]` section, resolver policy knobs.", "type": "object", "properties": { "fallback": { - "description": "`probe` (default) — PATH probe in canonical order when no signals\nmatch; `npm` — legacy silent fallback; `error` — refuse to proceed.", + "description": "`probe` (default), PATH probe in canonical order when no signals\nmatch; `npm`, legacy silent fallback; `error`, refuse to proceed.", "type": [ "null", "string" @@ -145,7 +157,7 @@ ] }, "on_mismatch": { - "description": "`warn` (default), `error`, `ignore` — how to react when declaration\n(manifest field) disagrees with detection (lockfile).", + "description": "`warn` (default), `error`, `ignore`, how to react when declaration\n(manifest field) disagrees with detection (lockfile).", "type": [ "null", "string" @@ -161,12 +173,12 @@ "additionalProperties": false }, "TaskRunnerSection": { - "description": "`[task_runner]` section — **deprecated**. Use `[tasks]` instead.\n\nKept for backward compatibility: existing `[task_runner].prefer` files\nkeep working (and emit a deprecation warning), but `[tasks].prefer` is the\nsupported successor — rank-only and able to name package managers, not just\ntask runners.", + "description": "`[task_runner]` section, **deprecated**. Use `[tasks]` instead.\n\nKept for backward compatibility: existing `[task_runner].prefer` files\nkeep working (and emit a deprecation warning), but `[tasks].prefer` is the\nsupported successor, rank-only and able to name package managers, not just\ntask runners.", "deprecated": true, "type": "object", "properties": { "prefer": { - "description": "**Deprecated — use `[tasks].prefer` instead** (rank-only, and accepts\npackage managers like `bun`, not just task runners). Migration:\n`[task_runner].prefer = [\"turbo\"]` → `[tasks].prefer = [\"turbo\"]`.\n\nLegacy behavior, still honored: a ranked preference list that\n*restricts* candidates to runners in the list (in listed order); a\nsame-named task under a runner not in the list is hard-rejected.\nValid values: `turbo`, `nx`, `make`, `just`, `task`, `mise`, `bacon`.", + "description": "**Deprecated, use `[tasks].prefer` instead** (rank-only, and accepts\npackage managers like `bun`, not just task runners). Migration:\n`[task_runner].prefer = [\"turbo\"]` → `[tasks].prefer = [\"turbo\"]`.\n\nLegacy behavior, still honored: a ranked preference list that\n*restricts* candidates to runners in the list (in listed order); a\nsame-named task under a runner not in the list is hard-rejected.\nValid values: `turbo`, `nx`, `make`, `just`, `task`, `mise`, `bacon`.", "deprecated": true, "type": "array", "items": { @@ -177,11 +189,11 @@ "additionalProperties": false }, "TasksSection": { - "description": "`[tasks]` section — persistent task-source preference for ambiguous task\nnames (a name that exists under more than one source, e.g. a `package.json`\nscript *and* a `turbo` task).\n\nBoth knobs speak the same label vocabulary: a label is a task runner\n(`turbo`, `make`, …), a package manager (`bun`, `npm`, `pnpm`, `yarn`,\n`deno`, …), or a source name (`package.json`, `deno`, …). Package-manager\nlabels map to the script source they run (`bun` → `package.json`).\nSelection here is **rank-only**: it never hard-rejects an unlisted source,\nit only reorders. An explicit CLI qualifier (`package.json:test`),\n`--runner`, or `--pm`/`RUNNER_PM` still outranks these file settings.", + "description": "`[tasks]` section, persistent task-source preference for ambiguous task\nnames (a name that exists under more than one source, e.g. a `package.json`\nscript *and* a `turbo` task).\n\nBoth knobs speak the same label vocabulary: a label is a task runner\n(`turbo`, `make`, …), a package manager (`bun`, `npm`, `pnpm`, `yarn`,\n`deno`, …), or a source name (`package.json`, `deno`, …). Package-manager\nlabels map to the script source they run (`bun` → `package.json`).\nSelection here is **rank-only**: it never hard-rejects an unlisted source;\nit only reorders. An explicit CLI qualifier (`package.json:test`),\n`--runner`, or `--pm`/`RUNNER_PM` still outranks these file settings.", "type": "object", "properties": { "overrides": { - "description": "Per-task pins that override [`Self::prefer`] for specific names:\n`overrides = { dev = \"bun\", build = \"turbo\" }`. A pin to a source the\ntask doesn't have falls through to the normal ranking (no hard error).", + "description": "Per-task pins that override `prefer` for specific names: `overrides = { dev = \"bun\", build = \"turbo\" }`. A pin to a source the task doesn't have falls through to the normal ranking (no hard error).", "type": "object", "additionalProperties": { "type": "string", @@ -250,12 +262,12 @@ "properties": { "chain": { "$ref": "#/$defs/ChainSection", - "description": "`[chain]` — failure policy for multi-task chains.", + "description": "`[chain]`, failure policy for multi-task chains.", "default": {} }, "github": { "$ref": "#/$defs/GitHubSection", - "description": "`[github]` — GitHub Actions integration (output grouping).", + "description": "`[github]`, GitHub Actions integration (output grouping).", "default": { "group_output": true, "group_parallel": true @@ -263,34 +275,34 @@ }, "install": { "$ref": "#/$defs/InstallSection", - "description": "`[install]` — restrict which detected PMs `runner install` runs.", + "description": "`[install]`, restrict which detected PMs `runner install` runs.", "default": {} }, "parallel": { "$ref": "#/$defs/ParallelSection", - "description": "`[parallel]` — presentation of parallel (`-p`) chain output.", + "description": "`[parallel]`, presentation of parallel (`-p`) chain output.", "default": { "grouped": false } }, "pm": { "$ref": "#/$defs/PmSection", - "description": "`[pm]` — per-ecosystem package-manager overrides.", + "description": "`[pm]`, per-ecosystem package-manager overrides.", "default": {} }, "resolution": { "$ref": "#/$defs/ResolutionSection", - "description": "`[resolution]` — resolver-policy knobs.", + "description": "`[resolution]`, resolver-policy knobs.", "default": {} }, "task_runner": { "$ref": "#/$defs/TaskRunnerSection", - "description": "`[task_runner]` — task-runner preferences. Deprecated; superseded\nby [`Self::tasks`].", + "description": "`[task_runner]`, task-runner preferences. Deprecated; superseded by `[tasks]`.", "default": {} }, "tasks": { "$ref": "#/$defs/TasksSection", - "description": "`[tasks]` — persistent task-source preference (global order + per-task pins).", + "description": "`[tasks]`, persistent task-source preference (global order + per-task pins).", "default": {} } }, diff --git a/schemas/why.example.json b/schemas/why.example.json index c8f2c775..49c04cd8 100644 --- a/schemas/why.example.json +++ b/schemas/why.example.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "kind": "runner.why", "root": "/path/to/project", "query": "t", diff --git a/schemas/why.schema.json b/schemas/why.schema.json index 63e53310..e5ccfb2a 100644 --- a/schemas/why.schema.json +++ b/schemas/why.schema.json @@ -308,7 +308,7 @@ "schema_version": { "description": "Schema contract version for this JSON payload.", "type": "integer", - "const": 1, + "const": 2, "minimum": 0, "format": "uint32" }, diff --git a/site/README.md b/site/README.md index 86b2c168..d4fd922c 100644 --- a/site/README.md +++ b/site/README.md @@ -46,5 +46,5 @@ site/ └── wrangler.jsonc # Static Assets binding, custom domain ``` -`dist/` is gitignored — produced by `build.ts`, consumed by +`dist/` is gitignored, produced by `build.ts`, consumed by `wrangler deploy`. diff --git a/site/build.ts b/site/build.ts index 97f3e26c..f43a7c1e 100644 --- a/site/build.ts +++ b/site/build.ts @@ -118,7 +118,7 @@ export async function build(options: BuildOptions = {}): Promise { const dirMode: DirMode = options.dir ?? "relative"; const display = (abs: string, base: string) => dirMode === "full" ? abs : relative(base, abs); - // Bun.build's outputs already hold the bundled bytes in memory — no re-read from disk. + // Bun.build's outputs already hold the bundled bytes in memory, no re-read from disk. // HTMLs get post-processed (placeholders, analytics, dead-script pruning) // and rewritten; everything else stays as Bun emitted it. const fromBundle = await Promise.all( diff --git a/site/src/404.html b/site/src/404.html index 92cf48eb..8f724108 100644 --- a/site/src/404.html +++ b/site/src/404.html @@ -3,7 +3,7 @@ - 404 — runner + 404, runner diff --git a/site/src/index.html b/site/src/index.html index eb9a18c6..f604a9f4 100644 --- a/site/src/index.html +++ b/site/src/index.html @@ -3,10 +3,10 @@ - runner — universal project task runner + runner, universal project task runner - + @@ -19,7 +19,7 @@

runner

- Universal project task runner. Auto-detects toolchain, provides unified CLI. + Universal project task runner. Auto-detects toolchain and provides a unified CLI.

v{{version}} · @@ -60,7 +60,7 @@

runner

- The npm package is a façade — installs only the prebuilt binary for your platform via + The npm package is a façade, which installs only the prebuilt binary for your platform via optionalDependencies. No postinstall, no network at install time.

@@ -96,7 +96,7 @@

runner

- Drop one line in your shell rc. Now <TAB> hits the binary and asks this project what tasks it knows about — grouped by + Drop one line in your shell rc. Now <TAB> hits the binary and asks this project what tasks it knows about, grouped by source, with descriptions. Same line registers both runner and run.

@@ -191,17 +191,16 @@

Task sources · 8

  1. - Auto-detection picks the right tool from your lockfiles. No config required — drop a runner.toml only when you want to override - it. + Auto-detection picks the right tool from your lockfiles. No config required: drop a runner.toml only when you want to override it.
  2. One CLI across npm, yarn, pnpm, bun, cargo, deno, uv, poetry, pipenv, go, bundler, composer.
  3. - Aggregates tasks from package.json, turbo.json(c), Makefile, justfile, Taskfile, deno.json(c), bacon.toml — qualified syntax (run + Aggregates tasks from package.json, turbo.json(c), Makefile, justfile, Taskfile, deno.json(c), bacon.toml, qualified syntax (run package.json:test) when names collide.
  4. Monorepo-aware: turbo, nx, pnpm, npm/yarn workspaces, Cargo workspaces.
  5. - No task by that name? Falls through to the package manager's exec primitive — npx, bunx, pnpm exec, + No task by that name? Falls through to the package manager's exec primitive, npx, bunx, pnpm exec, uv run, etc.
diff --git a/src/chain/exec.rs b/src/chain/exec.rs index 613fd0b5..25212dab 100644 --- a/src/chain/exec.rs +++ b/src/chain/exec.rs @@ -30,8 +30,8 @@ pub(crate) fn run_chain( // Pre-flight every task token before *any* sibling runs. Catches // the common UX trap where `runner run -s bb t lint:cargo` would // run `bb` and `t` to completion before bailing on the obvious - // typo at item 3. `precheck_task` is side-effect-free — no - // warnings emitted, no arrows printed, no subprocess spawned — + // typo at item 3. `precheck_task` is side-effect-free, no + // warnings emitted, no arrows printed, no subprocess spawned, // and only fires for errors we can determine purely from // `ctx.tasks` + the override shape. Errors that need the resolver // (PM-exec fallback miss, manifest mismatch) still surface at @@ -43,7 +43,7 @@ pub(crate) fn run_chain( } } - // Emit warnings on both success and error paths — a chain that + // Emit warnings on both success and error paths: a chain that // crashes halfway through should still surface the resolver // warnings it accumulated, not swallow them with the error. let result = match chain.mode { @@ -137,7 +137,7 @@ fn run_parallel_streaming( // Spawn loop. On any per-item failure (resolver error or the Install // bail-out below), already-spawned children would otherwise outlive - // this function — `std::process::Child::drop` does NOT kill the + // this function because `std::process::Child::drop` does NOT kill the // process. Cleanup explicitly: kill + reap accumulated children, // then join readers (their pipes close once the children are // reaped, so the threads exit on their own). @@ -154,9 +154,9 @@ fn run_parallel_streaming( Some(warnings), )?, ChainItemKind::Install { .. } => { - // Install is always Sequential in v1 (CLI rejects `-p` on - // `runner install`); reaching here would mean a synthetic - // Parallel chain was constructed elsewhere — bail loudly. + // Parallel install is supported with the install head run + // first. This executor only handles the parallel tasks + // that follow, so an Install item here is invalid. anyhow::bail!("install items cannot run in parallel chains") } }; @@ -267,7 +267,7 @@ struct GroupedTask { const READER_DRAIN_GRACE: std::time::Duration = std::time::Duration::from_millis(500); /// Parallel execution that buffers each task's output and displays it as one -/// contiguous block the moment that task finishes (completion order — first +/// contiguous block the moment that task finishes (completion order, first /// done, first shown). Under GitHub Actions each block is a `::group::` /// section; elsewhere it gets a plain header. See [`run_parallel`]. fn run_parallel_grouped( @@ -312,7 +312,7 @@ fn run_parallel_grouped( // unsizes to the trait object; `Arc::clone(&sink)` would instead // infer its generic from the annotation and fail to coerce. let dyn_sink: Arc = sink.clone(); - // No prefix — the group title identifies the task, while the sink + // No prefix: the group title identifies the task, while the sink // preserves stdout/stderr identity for replay. let readers = spawn_readers( vec![ @@ -503,7 +503,7 @@ fn write_timing_footer(footer: Option<&str>, colorize: bool) { } /// Kill + reap streaming-chain children that must not outlive an error -/// return — `Child::drop` does not kill, so every early exit routes +/// return: `Child::drop` does not kill, so every early exit routes /// through here. fn kill_and_reap>( children: I, @@ -515,7 +515,7 @@ fn kill_and_reap` or `::endgroup::` workflow - /// command at column 0 is rewritten so it can't nest inside — or - /// prematurely close — runner's own per-task group: the group title is + /// command at column 0 is rewritten so it can't nest inside, or + /// prematurely close, runner's own per-task group: the group title is /// surfaced as plain text and the endgroup is dropped. All other lines, /// including `::warning::`/`::error::`/`::notice::` annotations, replay /// verbatim. @@ -289,7 +289,7 @@ pub(crate) fn render_prefix(name: &str, width: usize, colorize: bool) -> String /// Spawn one reader thread per `(prefix, is_stderr, reader)` entry in /// `streams`. Each thread reads its `Read` line-by-line and pushes the /// result through `sink`. Returns the `Vec>` for the -/// spawned threads — the caller joins each handle once the underlying +/// spawned threads. The caller joins each handle once the underlying /// pipes close (which happens naturally when each child process exits /// and the OS tears its stdio fds down). pub(crate) fn spawn_readers( diff --git a/src/chain/parse.rs b/src/chain/parse.rs index a644ce0d..0bb65d47 100644 --- a/src/chain/parse.rs +++ b/src/chain/parse.rs @@ -7,7 +7,7 @@ use super::ChainItem; /// Parse a positional list of task names into a v1 chain. /// -/// v1 rules (reserved space for v2 quoted bundles — see spec §10): +/// v1 rules (reserved space for v2 quoted bundles, see spec §10): /// - Positionals containing whitespace are rejected. /// - Positionals starting with `-` are rejected. /// - At least one task is required. diff --git a/src/cli.rs b/src/cli.rs index 3b5a96b1..93c2ae50 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -76,7 +76,7 @@ fn env_suffix(var: &str) -> String { /// Cyan-styled env-var suffix for `--dir`. Kept alongside [`PM_HELP`] / /// [`RUNNER_HELP`] rather than as a plain `format!` inline in the doc /// comment so the `--dir` flag renders identically to every other flag's -/// `[env: VAR]` suffix — clap's own `env = "..."` attribute would style +/// `[env: VAR]` suffix; clap's own `env = "..."` attribute would style /// it differently and also print the variable's *current* value (e.g. /// `[env: RUNNER_DIR=]` when set-but-empty), which the other flags never /// do since their env fallback lives at the call site, not in clap. @@ -136,7 +136,7 @@ mod help_order { pub(super) const SCHEMA_VERSION: usize = 203; } /// Produce [`CompletionCandidate`]s for every detected task in the current -/// directory. Called lazily by clap's runtime completion engine — only runs +/// directory. Called lazily by clap's runtime completion engine, only runs /// when the shell is actually requesting completions, never during normal /// execution. fn task_candidates() -> Vec { @@ -159,7 +159,7 @@ fn completion_dir() -> std::io::Result { /// Mirror clap's `--dir` precedence at completion time. /// -/// Precedence (highest first) — same as the resolver at runtime so +/// Precedence (highest first), same as the resolver at runtime so /// the completion list matches the directory the user is about to /// dispatch against: /// 1. `--dir` parsed from the in-flight argv (the user is typing @@ -242,8 +242,8 @@ fn global_value_flags() -> Vec { /// Candidates for the trailing `args` positional of `run`. In chain mode /// (`-s`/`-p` typed before the first task) the trailing words are extra /// task names, so complete tasks; otherwise they are arguments forwarded -/// verbatim to the task, where suggesting task names would be noise — -/// complete nothing. +/// verbatim to the task, where suggesting task names would be noise, +/// so complete nothing. fn chain_args_candidates() -> Vec { let argv: Vec = std::env::args_os().collect(); if !chain_flag_precedes_first_task(&argv) { @@ -254,7 +254,7 @@ fn chain_args_candidates() -> Vec { /// Scan the in-flight completion argv for a chain-mode flag (`-s`/`-p`, /// long forms, or a short cluster like `-sk`) *before* the first task -/// word — mirroring dispatch, where `trailing_var_arg` means chain flags +/// word, mirroring dispatch, where `trailing_var_arg` means chain flags /// must precede task names and a later `-s`/`-p` is forwarded to the /// task instead. Same argv shape as [`cli_dir_from_argv`]: user words /// follow the first `--`, and the word after that is the binary name. @@ -262,7 +262,7 @@ fn chain_args_candidates() -> Vec { /// `--dir /some/path -s build` still detects the chain flag. fn chain_flag_precedes_first_task(argv: &[std::ffi::OsString]) -> bool { // Flags whose value arrives as the *next* word (the `=` form needs no - // special casing — it stays one word). Derived from the real clap + // special casing; it stays one word). Derived from the real clap // definition so a new value-taking global can't silently drift out of // sync with this scanner and get its value mistaken for the task. let value_flags = global_value_flags(); @@ -280,7 +280,7 @@ fn chain_flag_precedes_first_task(argv: &[std::ffi::OsString]) -> bool { "-s" | "--sequential" | "-p" | "--parallel" => return true, // The `run` subcommand token (`runner run …`); the alias // binary has no subcommand. Only the first bare word can be - // it — a task literally named `run` still terminates the + // it. A task literally named `run` still terminates the // scan below on any later occurrence. "run" | "r" if !subcommand_seen => subcommand_seen = true, _ if value_flags.iter().any(|flag| flag == word) => { @@ -361,14 +361,14 @@ fn task_candidates_from(tasks: &[crate::types::Task]) -> Vec Package > others`, then - // `display_order`, then recipes-before-aliases — see + // `display_order`, then recipes-before-aliases, see // `cmd::run::select::select_task_entry`). Previously the bare label came // from whichever source appeared first in detection order, which could // name a different source than the one `runner ` actually // dispatches to. The selector's `[task_runner].prefer` and nearest-config // (`source_depth`) tiebreaks need config plus a `ProjectContext` the // completion callback doesn't have, so the bare label aligns with the - // *default* tier only — still strictly better than detection order, and + // *default* tier only, still strictly better than detection order, and // the qualified `source:name` forms remain for exact disambiguation. let no_overrides = crate::resolver::ResolutionOverrides::default(); let bare_rank = |task: &crate::types::Task| { @@ -490,7 +490,7 @@ mod tests { #[test] fn chain_flag_detected_before_first_task() { - // `runner run -s build ` — chain mode, trailing words are tasks. + // `runner run -s build `, chain mode, trailing words are tasks. assert!(chain_flag_precedes_first_task(&osv(&[ "completer", "--", @@ -534,7 +534,7 @@ mod tests { #[test] fn chain_flag_after_first_task_is_forwarded_not_chain() { - // `run build -p 3000 ` — `-p` lands after the task, so + // `run build -p 3000 `, `-p` lands after the task, so // trailing_var_arg forwards it to the task; not chain mode. assert!(!chain_flag_precedes_first_task(&osv(&[ "completer", @@ -656,7 +656,7 @@ mod tests { ); assert!( values.contains(&"make:build".to_string()), - "Makefile is a real definition, not a passthrough — keep its qualified form" + "Makefile is a real definition, not a passthrough, keep its qualified form" ); assert!( values.contains(&"turbo:build".to_string()), @@ -668,7 +668,7 @@ mod tests { fn real_package_json_script_keeps_qualified_form_alongside_turbo() { // Regression guard: a real `"build": "vite build"` script that // happens to share its name with a `turbo.json` task must NOT be - // swallowed — the passthrough flag is set per-script-body during + // swallowed. The passthrough flag is set per-script-body during // detection, not inferred from name collisions alone. let tasks = vec![ // Same name, but `passthrough_to_turbo: false` because the @@ -698,7 +698,7 @@ mod tests { // `package.json` is detected first, but `runner build` dispatches to // turbo (Turbo > Package in the default selector tier). The bare // candidate's label must therefore name turbo, not the detection-order - // first source — otherwise the completion menu misreports what runs. + // first source; otherwise the completion menu misreports what runs. let tasks = vec![ task("build", TaskSource::PackageJson), task("build", TaskSource::TurboJson), @@ -774,7 +774,7 @@ mod tests { candidates .iter() .any(|c| c.get_value().to_string_lossy() == "build"), - "without a turbo.json twin, the passthrough is the only source — keep it" + "without a turbo.json twin, the passthrough is the only source; keep it" ); } @@ -831,7 +831,7 @@ mod tests { #[test] fn resolve_completion_dir_prefers_cli_over_env() { // `runner --dir /cli-target ` with `RUNNER_DIR=/env-target` - // set in the environment — completion should reflect the CLI + // set in the environment, completion should reflect the CLI // flag, matching clap's runtime precedence. let dir = resolve_completion_dir( Path::new("/tmp/workspace"), @@ -974,7 +974,7 @@ mod tests { #[test] fn info_subcommand_still_parses_but_is_hidden() { - // Deprecated alias — must keep parsing (with and without --json) + // Deprecated alias, must keep parsing (with and without --json) // so existing `runner info` invocations don't break … Cli::try_parse_from(["runner", "info"]).expect("`runner info` still parses"); Cli::try_parse_from(["runner", "info", "--json"]) @@ -1133,7 +1133,7 @@ pub(crate) struct Cli { /// Flags shared by both `runner` and `run`. Carried inline via /// `#[command(flatten)]` so each binary's `--help` lists them at the -/// same level as subcommand-specific arguments — clap unrolls them as +/// same level as subcommand-specific arguments; clap unrolls them as /// if they were defined on the parent struct. #[derive(Debug, Args)] pub(crate) struct GlobalOpts { @@ -1259,16 +1259,15 @@ pub(crate) struct GlobalOpts { )] pub quiet: bool, - /// Pin the `--json` output schema version. Currently always `1` — - /// kept for scripts that already pass it explicitly; any other value - /// is rejected. + /// Pin the `--json` output schema version. Currently always `2`; any other + /// value is rejected. #[arg( long = "schema-version", global = true, - value_parser = clap::value_parser!(u32).range(1..=1), + value_parser = clap::value_parser!(u32).range(2..=2), value_name = "N", display_order = help_order::SCHEMA_VERSION, - help = concat!("Pin ", cyan!("--json"), " schema (currently always ", cyan!("1"), ")"), + help = concat!("Pin ", cyan!("--json"), " schema (currently always ", cyan!("2"), ")"), )] pub schema_version: Option, } @@ -1286,7 +1285,7 @@ pub(crate) enum Command { #[arg(add = ArgValueCandidates::new(task_candidates))] task: Option, /// Arguments forwarded to the task, or extra task names in chain mode. - // In chain mode, chain-failure flags (`-k`) must precede task names — + // In chain mode, chain-failure flags (`-k`) must precede task names; // `trailing_var_arg` consumes everything after the first positional. #[arg( trailing_var_arg = true, @@ -1345,7 +1344,7 @@ pub(crate) enum Command { /// task list still parse as flags, not task names. #[arg(add = ArgValueCandidates::new(task_candidates))] tasks: Vec, - /// Chain mode flags `-s`/`-p` — govern the post-install tasks only. + /// Chain mode flags `-s`/`-p`, govern the post-install tasks only. #[command(flatten)] mode: ChainModeFlags, /// Chain failure-policy flags `-k`/`-K`. `-K` (kill siblings) only @@ -1364,7 +1363,7 @@ pub(crate) enum Command { include_framework: bool, }, - /// Deprecated alias for `list` — hidden, prints a warning, then + /// Deprecated alias for `list`, hidden, prints a warning, then /// renders the task list. Bare `runner` still shows the project /// dashboard; only the explicit `info` verb is deprecated. #[command(hide = true)] @@ -1399,7 +1398,7 @@ pub(crate) enum Command { /// Generate shell completions Completions { - /// Target shell — bare name (`zsh`) or full path (`/usr/bin/zsh`). + /// Target shell, bare name (`zsh`) or full path (`/usr/bin/zsh`). /// Defaults to `$SHELL`. #[arg(value_parser = crate::cmd::parse_shell_arg)] shell: Option, @@ -1511,7 +1510,7 @@ pub(crate) enum ConfigAction { // `-k`), but an *undefined* hyphen token after the first positional is // swallowed by `args` (`trailing_var_arg`) and forwarded to the task. // A leading `--help`/`--version` (before any task) instead surfaces as - // an `UnknownArgument` error — `task` takes no hyphen values — which + // an `UnknownArgument` error; `task` takes no hyphen values, which // `run_alias_in_dir` recognises as this binary's own help/version // request. `run -- --help` keeps forwarding literally. disable_help_flag = true, @@ -1527,7 +1526,7 @@ pub(crate) struct RunAliasCli { pub task: Option, /// Arguments forwarded to the task, or extra task names in chain mode. - // In chain mode, chain-failure flags (`-k`) must precede task names — + // In chain mode, chain-failure flags (`-k`) must precede task names; // `trailing_var_arg` consumes everything after the first positional. // That same rule forwards a *trailing* `--help`/`--version` to the task // rather than treating it as this binary's own. diff --git a/src/cmd/clean.rs b/src/cmd/clean.rs index 2153a441..43326178 100644 --- a/src/cmd/clean.rs +++ b/src/cmd/clean.rs @@ -1,4 +1,4 @@ -//! `runner clean` — remove caches and build artifacts for detected tools. +//! `runner clean`, remove caches and build artifacts for detected tools. use std::collections::HashSet; use std::io::Write as _; @@ -168,6 +168,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } diff --git a/src/cmd/completions.rs b/src/cmd/completions.rs index 7033253f..7fd1c37c 100644 --- a/src/cmd/completions.rs +++ b/src/cmd/completions.rs @@ -1,4 +1,4 @@ -//! `runner completions` — generate dynamic shell completion scripts. +//! `runner completions`, generate dynamic shell completion scripts. use std::io::{BufWriter, Write as _}; use std::path::{Path, PathBuf}; @@ -15,8 +15,8 @@ use crate::complete::SHELLS; /// Resolves the target shell from the explicit argument, `$SHELL`, or the /// presence of `$PSModulePath` (PowerShell sets it but never `$SHELL`), /// looks up the matching completer from our [`SHELLS`] table, and calls -/// [`clap_complete::env::EnvCompleter::write_registration`] directly — once -/// per binary — so users only need a single +/// [`clap_complete::env::EnvCompleter::write_registration`] directly, once +/// per binary, so users only need a single /// `eval "$(runner completions zsh)"` in their rc file to get completion /// for both CLIs. /// @@ -25,9 +25,9 @@ use crate::complete::SHELLS; /// stderr. Otherwise they go to stdout, byte-for-byte the same output the /// command has always produced. pub(crate) fn completions(shell: Option, output: Option<&Path>) -> Result<()> { - let shell = shell.or_else(detect_shell).context( - "could not detect shell — set $SHELL or pass explicitly: runner completions zsh", - )?; + let shell = shell + .or_else(detect_shell) + .context("could not detect shell, set $SHELL or pass explicitly: runner completions zsh")?; let shell_name = env_shell_name(shell); let completer = SHELLS @@ -65,8 +65,8 @@ pub(crate) fn completions(shell: Option, output: Option<&Path>) -> Result Ok(()) } -/// Emit `runner`'s registration, and — when a sibling `run` binary was -/// located — `run`'s registration separated by a blank line. Shared +/// Emit `runner`'s registration, and, when a sibling `run` binary was +/// located, `run`'s registration separated by a blank line. Shared /// between the stdout and file-output paths so the byte stream is /// identical either way. fn write_registrations( @@ -97,7 +97,7 @@ fn write_registrations( /// Resolve the sibling `run` binary next to the `runner` executable so the /// generated completion script can invoke it directly. Returns `None` when -/// no sibling exists — the caller skips the `run` registration in that +/// no sibling exists; the caller skips the `run` registration in that /// case rather than guessing at PATH resolution. fn sibling_run_binary(runner_exe: &Path) -> Option { let parent = runner_exe.parent()?; @@ -154,8 +154,8 @@ fn shell_from_path(path: &Path) -> Option { pub(crate) fn parse_shell_arg(raw: &str) -> Result { shell_from_path(Path::new(raw)).ok_or_else(|| { format!( - "unsupported shell: {raw:?} (accepted: bash, zsh, fish, elvish, pwsh|powershell — \ - bare name or full path)" + "unsupported shell: {raw:?} (accepted: bash, zsh, fish, elvish, pwsh|powershell, bare \ + name or full path)" ) }) } @@ -190,7 +190,7 @@ mod tests { #[test] fn parse_shell_arg_accepts_absolute_path() { // Regression guard: `runner completions $SHELL` (which expands to - // a full path) must succeed — the stock clap `ValueEnum` parser + // a full path) must succeed; the stock clap `ValueEnum` parser // rejected anything but the bare name, so this is the whole reason // the custom parser exists. assert_eq!(parse_shell_arg("/usr/bin/zsh").unwrap(), Shell::Zsh); @@ -209,8 +209,8 @@ mod tests { #[test] fn parse_shell_arg_error_lists_accepted_forms() { let err = parse_shell_arg("ksh").expect_err("ksh should be rejected"); - // Surface every accepted shell — including the `pwsh|powershell` - // pair — so users hit by the rejection see the full menu. + // Surface every accepted shell, including the `pwsh|powershell` + // pair, so users hit by the rejection see the full menu. for needle in ["bash", "zsh", "fish", "elvish", "pwsh", "powershell"] { assert!( err.contains(needle), diff --git a/src/cmd/config.rs b/src/cmd/config.rs index 56cec261..ad86d06f 100644 --- a/src/cmd/config.rs +++ b/src/cmd/config.rs @@ -1,4 +1,4 @@ -//! `runner config` — scaffold, inspect, and validate the project-level +//! `runner config`, scaffold, inspect, and validate the project-level //! `runner.toml`. These actions operate on the file directly and must run //! *before* the resolver's own `config::load` (which aborts on a malformed //! file); `config validate` exists precisely to report that condition. @@ -24,7 +24,7 @@ pub(crate) fn config(dir: &Path, action: ConfigAction) -> Result { } } -/// `runner config init` — write the commented starter template to +/// `runner config init`, write the commented starter template to /// `/runner.toml`. Refuses to clobber an existing file unless `force`. fn init(dir: &Path, force: bool) -> Result { let target = dir.join(CONFIG_FILENAME); @@ -38,7 +38,7 @@ fn init(dir: &Path, force: bool) -> Result { return Ok(2); } // config::INIT_TEMPLATE carries its own repo-relative `#:schema` pragma - // (for editing this repo's copy) — swap it for the real published URL + // (for editing this repo's copy); swap it for the real published URL // rather than stacking a second pragma line in the user's project. let body = strip_leading_schema_pragma(config::INIT_TEMPLATE); let contents = format!("#:schema {}\n\n{body}", crate::schema::config_schema_url()); @@ -62,7 +62,7 @@ fn strip_leading_schema_pragma(template: &str) -> &str { after_line.strip_prefix('\n').unwrap_or(after_line) } -/// `runner config show` — render the effective config (file values merged +/// `runner config show`, render the effective config (file values merged /// with built-in defaults) as TOML, or JSON with `--json`. Propagates parse /// errors; use `validate` for a non-fatal diagnostic. fn show(dir: &Path, json: bool) -> Result { @@ -87,7 +87,7 @@ fn show(dir: &Path, json: bool) -> Result { "{} {} {}", "config:".bold(), target.display(), - "(not found — built-in defaults)".dimmed(), + "(not found, built-in defaults)".dimmed(), ); } print!("{}", toml::to_string_pretty(&cfg)?); @@ -95,7 +95,7 @@ fn show(dir: &Path, json: bool) -> Result { Ok(0) } -/// `runner config validate` — parse the file and run the same field and +/// `runner config validate`, parse the file and run the same field and /// failure-policy checks a live dispatch applies. Returns the exit code: /// `0` when valid (or absent), `2` on any parse or policy error. fn validate(dir: &Path) -> i32 { @@ -117,7 +117,7 @@ fn validate(dir: &Path) -> i32 { }; // Unknown sections/fields are tolerated (forward compat) but worth - // flagging here — a typo, or a key from a newer runner. They don't make + // flagging here, a typo, or a key from a newer runner. They don't make // the file invalid, so they warn without changing the exit code. for warning in &loaded.warnings { eprintln!("{} {warning}", "warn:".yellow().bold()); @@ -131,7 +131,7 @@ fn validate(dir: &Path) -> i32 { 0 } -/// `runner config path` — print the resolved `runner.toml` path (whether or +/// `runner config path`, print the resolved `runner.toml` path (whether or /// not it exists), one line, for scripting. Always succeeds. fn path(dir: &Path) -> i32 { println!("{}", dir.join(CONFIG_FILENAME).display()); diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index 9eeaaf19..7fb9a423 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -1,4 +1,4 @@ -//! `runner doctor` — dump every signal the resolver considers. +//! `runner doctor`, dump every signal the resolver considers. //! //! Surface for users (and bug reports) to inspect what runner sees in the current project: //! detected package managers and task runners, the manifest declaration if any, lockfile presence, @@ -15,7 +15,8 @@ use anyhow::Result; use colored::Colorize; use serde_json::{Map, Value}; -use crate::resolver::ResolutionOverrides; +use crate::cmd::install::InstallPlan; +use crate::resolver::{ResolutionOverrides, ResolveError}; use crate::schema::Project; use crate::schema::doctor::DoctorReport; use crate::types::ProjectContext; @@ -49,14 +50,18 @@ pub(crate) fn doctor( // `Value` keeps that ergonomics while the JSON contract itself stays // typed via `Project`. let report = serde_json::to_value(&project)?; - print_human(&report, overrides); + // A plan that refuses to resolve (`on_collision = "error"`, an override + // naming an undetected PM) is the diagnosis, so it is rendered rather than + // propagated, same contract as the resolver error above. + let plan = super::install::plan_install(ctx, overrides); + print_human(&report, overrides, plan.as_ref()); Ok(()) } /// Legacy stub retained for the existing tests that exercise /// `build_report` directly. Pure passthrough to `Project::build` + -/// `serde_json::to_value` — same contract, same shape. +/// `serde_json::to_value`, same contract, same shape. #[cfg(test)] fn build_report(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> Value { serde_json::to_value(Project::build(ctx, overrides)) @@ -67,7 +72,11 @@ fn build_report(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> Value clippy::too_many_lines, reason = "linear section-by-section renderer; splitting hurts readability" )] -fn print_human(report: &Value, overrides: &ResolutionOverrides) { +fn print_human( + report: &Value, + overrides: &ResolutionOverrides, + plan: Result<&InstallPlan, &ResolveError>, +) { let root = report["root"].as_str().unwrap_or("?"); println!( "{} {}", @@ -150,6 +159,15 @@ fn print_human(report: &Value, overrides: &ResolutionOverrides) { ), ); } + if !overrides.install_pms.is_empty() { + let pms = overrides + .install_pms + .iter() + .map(|pm| pm.label()) + .collect::>() + .join(", "); + writeln_field(out, "install.pms", &pms); + } writeln_field( out, "fallback", @@ -203,18 +221,74 @@ fn print_human(report: &Value, overrides: &ResolutionOverrides) { writeln!(out, " {:<20}{}", "node scripts".red(), err.red()) .expect("writeln to String should not fail"); } + match plan { + Ok(plan) => { + let pms = plan + .pms + .iter() + .map(|pm| pm.label()) + .collect::>() + .join(", "); + if !pms.is_empty() { + writeln_field(out, "install", &pms); + } + for shadow in &plan.shadowed { + writeln_field( + out, + shadow.dir, + &format!( + "{} installs it, {} shadowed", + shadow.winner.label(), + shadow.loser.label(), + ), + ); + } + for collision in &plan.collisions { + let names = collision + .writers + .iter() + .map(|pm| pm.label()) + .collect::>() + .join(" then "); + writeln_field(out, "shared tree", &format!("{names} (serialized)")); + } + } + Err(err) => { + writeln!(out, " {:<20}{}", "install".red(), err.to_string().red()) + .expect("writeln to String should not fail"); + } + } }); - let warnings = report["warnings"].as_array().cloned().unwrap_or_default(); + // Detection warnings, plus the collisions the install plan kept. The + // collision is the plan's verdict on the effective install set, not a fact + // about the tree, so it lives here and nowhere else; commands that never + // install have nothing to say about it. + let mut warnings: Vec<(String, String)> = report["warnings"] + .as_array() + .map(|ws| { + ws.iter() + .map(|w| { + ( + w["source"].as_str().unwrap_or("?").to_string(), + w["detail"].as_str().unwrap_or("?").to_string(), + ) + }) + .collect() + }) + .unwrap_or_default(); + if let Ok(plan) = plan { + warnings.extend(plan.collisions.iter().map(|collision| { + ( + "install".to_string(), + crate::cmd::install::collision_warning(collision.dir, &collision.writers), + ) + })); + } if !warnings.is_empty() { println!("{}", "Warnings".bold()); - for w in &warnings { - println!( - " {} {}: {}", - "warn:".yellow().bold(), - w["source"].as_str().unwrap_or("?"), - w["detail"].as_str().unwrap_or("?"), - ); + for (source, detail) in &warnings { + println!(" {} {source}: {detail}", "warn:".yellow().bold()); } } } @@ -270,6 +344,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } @@ -328,7 +403,7 @@ mod tests { let ctx = context(); let report = build_report(&ctx, &ResolutionOverrides::default()); - assert_eq!(report["schema_version"], 1); + assert_eq!(report["schema_version"], 2); } #[test] @@ -371,7 +446,7 @@ mod tests { use crate::detect::detect; use crate::tool::test_support::TempDir; - // Manifest declaration disagrees with the detected lockfile — + // When the manifest declaration disagrees with the detected lockfile, // the resolver emits a `package.json` warning. Doctor should // surface it alongside whatever ctx.warnings already carries. let dir = TempDir::new("doctor-merges-warnings"); diff --git a/src/cmd/info.rs b/src/cmd/info.rs index 1635f5b8..2120c26b 100644 --- a/src/cmd/info.rs +++ b/src/cmd/info.rs @@ -1,4 +1,4 @@ -//! `runner info` — print detected project context to stdout. +//! `runner info`, print detected project context to stdout. use std::ffi::OsString; use std::fmt::Write as _; @@ -114,7 +114,7 @@ fn release_url() -> String { fn bin_name_from_arg0(arg0: Option) -> String { // Delegate to the canonical helper so the banner presents the same - // `run` / `runner` identity as `--version` and `--help` — notably it + // `run` / `runner` identity as `--version` and `--help`; notably it // strips the Windows `.exe` suffix that `argv[0]` carries. arg0.and_then(|raw| crate::bin_name_from_arg0(&raw)) .unwrap_or_else(|| "runner".to_string()) diff --git a/src/cmd/install.rs b/src/cmd/install.rs index 04acad55..c254ddc1 100644 --- a/src/cmd/install.rs +++ b/src/cmd/install.rs @@ -1,4 +1,4 @@ -//! `runner install` — install dependencies via every detected package manager. +//! `runner install`, install dependencies via every detected package manager. use std::any::Any; use std::process::{Command, Stdio}; @@ -9,9 +9,9 @@ use anyhow::{Result, bail}; use colored::Colorize; use crate::chain::mux::{LineSink, StdioSink, prefix_width, render_prefix, spawn_readers}; -use crate::resolver::{ResolutionOverrides, ResolveError, ScriptPolicy}; +use crate::resolver::{CollisionPolicy, ResolutionOverrides, ResolveError, Resolver, ScriptPolicy}; use crate::tool; -use crate::types::{DetectionWarning, PackageManager, ProjectContext, TaskRunner, version_matches}; +use crate::types::{PackageManager, ProjectContext, TaskRunner, version_matches}; /// Install dependencies for each detected package manager. /// @@ -47,13 +47,13 @@ pub(crate) fn install_pms( bail!("No package manager detected."); } - // Resolved before the GHA group opens so a refused override doesn't - // emit an empty `runner: install` group — same rationale as the + // Planned before the GHA group opens so a refused override doesn't + // emit an empty `runner: install` group, same rationale as the // no-PM bail above. - let pms = select_install_pms(ctx, overrides)?; + let plan = plan_install(ctx, overrides)?; - warn_selected_collisions(ctx, &pms, overrides); - warn_unsupported_script_policy(&pms, overrides); + report_plan(&plan, overrides); + warn_unsupported_script_policy(&plan.pms, overrides); // Collapse the whole install (single- or multi-PM) under one // `runner: install` GitHub Actions group when enabled. @@ -72,20 +72,20 @@ pub(crate) fn install_pms( suggest_version_switch(ctx); } - if let [pm] = pms.as_slice() { + if let [pm] = plan.pms.as_slice() { return install_single(ctx, *pm, frozen, overrides); } - run_installs_parallel(ctx, &pms, frozen, overrides) + run_installs_parallel(ctx, &plan, frozen, overrides) } /// Which PMs this invocation installs with, in precedence order: /// /// 1. The cross-ecosystem `--pm`/`RUNNER_PM` override (which also affects -/// script dispatch) — installs with that PM alone; errors if it isn't +/// script dispatch), installs with that PM alone; errors if it isn't /// detected. /// 2. The install-scoped allowlist `RUNNER_INSTALL_PMS` / `[install].pms` -/// (resolved into `overrides.install_pms`) — installs with the detected +/// (resolved into `overrides.install_pms`), installs with the detected /// PMs in that list, preserving detection order; errors if a listed PM /// isn't detected. /// 3. Otherwise every detected PM. @@ -98,18 +98,14 @@ fn select_install_pms( overrides: &ResolutionOverrides, ) -> Result, ResolveError> { if let Some(o) = &overrides.pm { - return if ctx.package_managers.contains(&o.pm) { - Ok(vec![o.pm]) - } else { - Err(ResolveError::PmOverrideNotDetected { + if !ctx.package_managers.contains(&o.pm) { + return Err(ResolveError::PmOverrideNotDetected { pm: o.pm, origin: o.origin.clone(), detected: ctx.package_managers.clone(), - }) - }; - } - - if !overrides.install_pms.is_empty() { + }); + } + } else { let missing: Vec = overrides .install_pms .iter() @@ -122,56 +118,220 @@ fn select_install_pms( detected: ctx.package_managers.clone(), }); } - // Keep detection order; the allowlist only filters, never reorders. - return Ok(ctx + } + + Ok(effective_install_pms(ctx, overrides)) +} + +/// The same selection as [`select_install_pms`] with the not-detected +/// errors dropped: an override naming an absent PM narrows to nothing +/// instead of failing. Reporting surfaces need the effective set without +/// inheriting dispatch's fatal cases; `doctor` must survive the broken +/// config it exists to diagnose. +fn effective_install_pms( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, +) -> Vec { + // Detection order throughout; overrides only filter, never reorder. + if let Some(o) = &overrides.pm { + return ctx .package_managers .iter() .copied() - .filter(|pm| overrides.install_pms.contains(pm)) - .collect()); + .filter(|pm| *pm == o.pm) + .collect(); } + if overrides.install_pms.is_empty() { + return ctx.package_managers.clone(); + } + ctx.package_managers + .iter() + .copied() + .filter(|pm| overrides.install_pms.contains(pm)) + .collect() +} - Ok(ctx.package_managers.clone()) +/// A directory the install set still writes with two or more managers, because +/// the user named them all. Its writers run in sequence, since concurrent +/// installs over one tree corrupt it. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct CollisionDir { + pub dir: &'static str, + pub writers: Vec, } -/// Warn about an install-dir collision only when the *selected* PM set -/// still collides. Detection records the collision over every detected PM -/// (so `doctor` reports it), but `[install].pms` / `RUNNER_INSTALL_PMS` may -/// already have narrowed install to a single writer — in which case there -/// is nothing left to warn about. -fn warn_selected_collisions( +/// A writer dropped from the install set because another manager owns the +/// directory it would have written. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct Shadowed { + pub loser: PackageManager, + pub winner: PackageManager, + pub dir: &'static str, +} + +/// What this invocation will install with, and what it decided about the +/// directories two package managers would otherwise write at once. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct InstallPlan { + /// The package managers that actually run, in detection order. + pub pms: Vec, + /// Writers dropped because another writer owns their directory. + pub shadowed: Vec, + /// Directories kept with two or more writers on the user's say-so. The + /// warning text and the serial run-order both derive from this. + pub collisions: Vec, +} + +/// Resolve the install set and every install-directory collision in it. +/// +/// Detection records which managers write which directory ([`ProjectContext::install_dirs`]) +/// without judging it, because whether a shared directory is a collision +/// depends on the install set, which only overrides can settle. This is where +/// it gets settled, and it is the only place: nothing else in the codebase +/// decides what "colliding" means. +/// +/// # Errors +/// +/// [`ResolveError::InstallDirCollision`] under +/// [`CollisionPolicy::Error`], plus the not-detected errors from +/// [`select_install_pms`]. +pub(crate) fn plan_install( ctx: &ProjectContext, - pms: &[PackageManager], overrides: &ResolutionOverrides, -) { - if overrides.no_warnings { - return; - } - for warning in &ctx.warnings { - if let Some(reduced) = still_colliding(warning, pms) { - eprintln!("{} {reduced}", "warn:".yellow().bold()); +) -> Result { + let mut plan = InstallPlan { + pms: select_install_pms(ctx, overrides)?, + shadowed: Vec::new(), + collisions: Vec::new(), + }; + + for install_dir in &ctx.install_dirs { + let writers: Vec = install_dir + .writers + .iter() + .copied() + .filter(|pm| plan.pms.contains(pm)) + .collect(); + if writers.len() < 2 { + continue; + } + if overrides.on_collision == CollisionPolicy::Error { + return Err(ResolveError::InstallDirCollision { + dir: install_dir.dir, + writers, + }); + } + // Naming several writers in the allowlist is consent: the user knows + // both write the tree and wants both to run. Runner honours it, warns + // once, and serializes them so consent doesn't become corruption. + if consented_to(&writers, overrides) { + plan.collisions.push(CollisionDir { + dir: install_dir.dir, + writers, + }); + continue; + } + let winner = dir_winner(ctx, overrides, &writers); + for loser in writers.iter().copied().filter(|pm| *pm != winner) { + plan.shadowed.push(Shadowed { + loser, + winner, + dir: install_dir.dir, + }); + plan.pms.retain(|pm| *pm != loser); } } + + Ok(plan) } -/// If `warning` is an install-dir collision that still has ≥2 writers once -/// narrowed to the `selected` install set, return the reduced warning to -/// emit; otherwise `None`. Pure so the narrowing is unit-testable. -fn still_colliding( - warning: &DetectionWarning, - selected: &[PackageManager], -) -> Option { - let DetectionWarning::InstallDirCollision { dir, pms: writers } = warning else { - return None; - }; - // Copy the `&'static str` out of the by-ref match binding (`&&str`). - let &dir = dir; - let still: Vec = writers +/// The warning shown when the install set keeps two or more writers on one +/// directory. They run serially, so nothing corrupts, but the redundant second +/// install is worth flagging. +pub(crate) fn collision_warning(dir: &str, writers: &[PackageManager]) -> String { + let list = writers .iter() - .copied() - .filter(|pm| selected.contains(pm)) - .collect(); - (still.len() >= 2).then_some(DetectionWarning::InstallDirCollision { dir, pms: still }) + .map(|pm| pm.label()) + .collect::>() + .join(", "); + let first = writers.first().map_or("bun", |pm| pm.label()); + format!( + "{list} all install into {dir}/, and the install allowlist names them all, so they run \ + one after another over the same tree instead of one of them being skipped. Drop all but \ + `{first}` from `[install].pms` (or `RUNNER_INSTALL_PMS`) to skip the redundant install." + ) +} + +/// Whether the user named two or more of `writers` in the install allowlist, +/// rather than runner having swept them in by detecting everything. +fn consented_to(writers: &[PackageManager], overrides: &ResolutionOverrides) -> bool { + writers + .iter() + .filter(|pm| overrides.install_pms.contains(pm)) + .count() + >= 2 +} + +/// Which writer owns a shared install directory. +/// +/// The only shared directory today is `node_modules`, whose writers are all +/// node-ecosystem, so the winner is the PM the resolver already picks for +/// `package.json` scripts (lockfile, `packageManager`, `[pm].node`, PATH +/// probe). Reusing that decision keeps one project from having two different +/// primary package managers depending on whether it runs a script or an +/// install, and lets `[pm].node = "deno"` hand deno the tree without a second +/// knob. +/// +/// The winner logic is therefore node-specific. A future non-node shared +/// directory would not match the node resolver's pick and would fall back to +/// the first writer in detection order, a deterministic default that whoever +/// adds that directory should replace with real resolution for its ecosystem. +fn dir_winner( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, + writers: &[PackageManager], +) -> PackageManager { + let resolved = Resolver::new(ctx, overrides) + .resolve_node_pm() + .ok() + .map(|decision| decision.pm) + .filter(|pm| writers.contains(pm)); + resolved.unwrap_or_else(|| { + writers + .first() + .copied() + .expect("dir_winner is only called with at least two writers") + }) +} + +/// Print what the plan decided: the collisions the user asked for, then the +/// writers that lost a directory. A skipped install is never silent; that is +/// how a lockfile goes stale without anyone noticing. +fn report_plan(plan: &InstallPlan, overrides: &ResolutionOverrides) { + if !overrides.no_warnings { + for collision in &plan.collisions { + eprintln!( + "{} install: {}", + "warn:".yellow().bold(), + collision_warning(collision.dir, &collision.writers), + ); + } + } + for shadow in &plan.shadowed { + eprintln!( + "{}", + format!( + "{}/: {} installs it, {} shadowed (run both with `[install].pms = [\"{}\", \ + \"{}\"]`)", + shadow.dir, + shadow.winner.label(), + shadow.loser.label(), + shadow.winner.label(), + shadow.loser.label(), + ) + .dimmed(), + ); + } } /// Run a single PM's install in the foreground, inheriting stdio. @@ -192,81 +352,143 @@ fn install_single( }) } -/// Run every detected package manager's install in parallel, multiplexing -/// stdout/stderr through a [`LineSink`] so each line is prefixed with the -/// PM that produced it. +/// Split the plan into lanes: the package managers that share an install +/// directory form one lane and run in sequence; everything else gets a lane of +/// its own. Lanes are emitted in detection order, as are the managers inside +/// one. +fn install_lanes(plan: &InstallPlan) -> Vec> { + let mut lanes: Vec> = Vec::new(); + for pm in &plan.pms { + if lanes.iter().flatten().any(|queued| queued == pm) { + continue; + } + match plan + .collisions + .iter() + .find(|collision| collision.writers.contains(pm)) + { + Some(collision) => lanes.push(collision.writers.clone()), + None => lanes.push(vec![*pm]), + } + } + lanes +} + +/// Run the plan's lanes concurrently, multiplexing stdout/stderr through a +/// [`LineSink`] so each line is prefixed with the PM that produced it. +/// +/// Managers sharing an install directory run in sequence inside their lane: +/// two installs writing one tree at the same time corrupt it. /// -/// Failure policy mirrors chain mode's `FailFast` default: record the -/// first non-zero exit code, let the remaining installs finish on their -/// own. Killing siblings on first failure (the `KillOnFail` analogue) -/// isn't exposed yet — the v1 `runner install` CLI has no flag for it, -/// and the conservative default for a top-level command is "don't tear -/// down the user's slow `cargo fetch` because `npm` blew up on a 404." +/// Failure policy mirrors chain mode's `FailFast` default: record the first +/// non-zero exit code (by detection order); let the other lanes finish on their +/// own. A failure *inside* a lane stops that lane, because the next manager in +/// it would install over the tree the failed one left behind. fn run_installs_parallel( ctx: &ProjectContext, - pms: &[PackageManager], + plan: &InstallPlan, frozen: bool, overrides: &ResolutionOverrides, ) -> Result { - use std::process::Child; - - let names: Vec<&str> = pms.iter().map(|pm| pm.label()).collect(); + let lanes = install_lanes(plan); + let names: Vec<&str> = plan.pms.iter().map(|pm| pm.label()).collect(); let width = prefix_width(&names); let colorize = colored::control::SHOULD_COLORIZE.should_colorize(); let sink: Arc = Arc::new(StdioSink); let directive = script_directive(overrides); - let mut children: Vec<(PackageManager, Child)> = Vec::with_capacity(pms.len()); - let mut reader_handles = Vec::new(); - - let spawn_outcome: Result<()> = (|| { - for pm in pms { - eprintln!("{} {}", "installing with".dimmed(), pm.label().bold()); - let mut cmd = build_install_command(ctx, *pm, frozen, directive); - super::configure_command(&mut cmd, &ctx.root, overrides); - cmd.stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - let mut child = cmd.spawn()?; - let prefix = render_prefix(pm.label(), width, colorize); - let stdout: Box = - Box::new(child.stdout.take().expect("stdout piped")); - let stderr: Box = - Box::new(child.stderr.take().expect("stderr piped")); - reader_handles.extend(spawn_readers( - vec![ - (prefix.clone(), false, stdout), - (prefix.clone(), true, stderr), - ], - &sink, - )); - children.push((*pm, child)); + let outcomes: Vec>> = std::thread::scope(|scope| { + // Every lane is spawned before any is joined: joining as we go would + // serialize the lanes, which is the whole thing this function exists + // not to do. + let mut handles = Vec::with_capacity(lanes.len()); + for lane in &lanes { + let sink = Arc::clone(&sink); + handles.push(scope.spawn(move || { + run_lane( + ctx, lane, frozen, overrides, &sink, width, colorize, directive, + ) + })); } - Ok(()) - })(); - if let Err(e) = spawn_outcome { - for (_, mut c) in children { - let _ = c.kill(); - let _ = c.wait(); - } - for h in reader_handles { - join_reader_thread(h); + handles + .into_iter() + .map(|handle| { + handle.join().unwrap_or_else(|payload| { + bail!("install lane panicked: {}", panic_payload(&*payload)) + }) + }) + .collect() + }); + + let mut failures: Vec<(PackageManager, i32)> = Vec::new(); + for outcome in outcomes { + match outcome { + Ok(Some(failure)) => failures.push(failure), + Ok(None) => {} + Err(e) => return Err(e), } - return Err(e); } + let index_of = |pm: PackageManager| plan.pms.iter().position(|p| *p == pm); + Ok(failures + .into_iter() + .min_by_key(|(pm, _)| index_of(*pm)) + .map_or(0, |(_, code)| code)) +} + +/// Run one lane's installs back to back, returning the lane's first failure. +#[allow( + clippy::too_many_arguments, + reason = "one lane's worth of the parallel executor's state; bundling it into a struct buys \ + nothing at one call site" +)] +fn run_lane( + ctx: &ProjectContext, + lane: &[PackageManager], + frozen: bool, + overrides: &ResolutionOverrides, + sink: &Arc, + width: usize, + colorize: bool, + directive: tool::ScriptDirective, +) -> Result> { + for pm in lane { + eprintln!("{} {}", "installing with".dimmed(), pm.label().bold()); + let mut cmd = build_install_command(ctx, *pm, frozen, directive); + super::configure_command(&mut cmd, &ctx.root, overrides); + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = cmd.spawn()?; + let prefix = render_prefix(pm.label(), width, colorize); + let stdout: Box = + Box::new(child.stdout.take().expect("stdout piped")); + let stderr: Box = + Box::new(child.stderr.take().expect("stderr piped")); + let readers = spawn_readers( + vec![(prefix.clone(), false, stdout), (prefix, true, stderr)], + sink, + ); - let mut first_failure: Option = None; - for (_, mut child) in children { - let status = child.wait()?; + let waited = match child.wait() { + Ok(status) => Ok(status), + Err(e) => { + // A failed wait can leave the process and its pipe writers + // alive. Stop and reap it before joining readers waiting for + // EOF, then propagate the original wait error. + let _ = child.kill(); + let _ = child.wait(); + Err(e) + } + }; + for handle in readers { + join_reader_thread(handle); + } + let status = waited?; if !status.success() { - first_failure.get_or_insert_with(|| super::exit_code(status)); + return Ok(Some((*pm, super::exit_code(status)))); } } - for h in reader_handles { - join_reader_thread(h); - } - - Ok(first_failure.unwrap_or(0)) + Ok(None) } fn join_reader_thread(handle: JoinHandle<()>) { @@ -388,7 +610,7 @@ enum ForceSupport { AlreadyRuns, /// Denies dependency build scripts by default and re-enables them only /// through a manifest allowlist runner must not write (bun - /// `trustedDependencies`, pnpm >=10 `onlyBuiltDependencies`) — so force-on + /// `trustedDependencies`, pnpm >=10 `onlyBuiltDependencies`), so force-on /// cannot be expressed by a flag. A force-on request is reported and the /// install proceeds at the manager's default. NotExpressible, @@ -424,10 +646,10 @@ const fn force_support(pm: PackageManager) -> ForceSupport { /// Unlike the cosmetic collision/version warnings, both notices fire /// unconditionally when their policy is active and are *not* silenced by /// `--no-warnings` / `RUNNER_NO_WARNINGS`: -/// - **deny** is a security-relevant disclosure — the unsupported managers +/// - **deny** is a security-relevant disclosure: the unsupported managers /// (bundler, uv/poetry/pipenv, cargo, go) execute arbitrary install-time /// code and have no flag to skip it, so a dropped deny must never hide. -/// - **force-on** is a request-fidelity disclosure — bun and pnpm (>=10) deny +/// - **force-on** is a request-fidelity disclosure: bun and pnpm (>=10) deny /// dependency build scripts by default and only a manifest allowlist runner /// won't write re-enables them, so the user learns their `--scripts` couldn't /// be applied rather than assuming it was. @@ -513,15 +735,17 @@ mod tests { use std::path::PathBuf; use super::{ - DenySupport, ForceSupport, build_install_command, deny_support, force_support, - script_directive, select_install_pms, unforceable_managers, unsupported_deny_managers, + CollisionDir, DenySupport, ForceSupport, InstallPlan, Shadowed, build_install_command, + deny_support, force_support, install_lanes, plan_install, script_directive, + select_install_pms, unforceable_managers, unsupported_deny_managers, warn_unsupported_script_policy, }; use crate::resolver::{ - OverrideOrigin, PmOverride, ResolutionOverrides, ResolveError, ScriptPolicy, + CollisionPolicy, OverrideOrigin, PmOverride, ResolutionOverrides, ResolveError, + ScriptPolicy, }; use crate::tool::ScriptDirective; - use crate::types::{Ecosystem, PackageManager, ProjectContext}; + use crate::types::{Ecosystem, InstallDir, PackageManager, ProjectContext}; fn context(pms: Vec) -> ProjectContext { ProjectContext { @@ -532,6 +756,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } @@ -553,7 +778,7 @@ mod tests { #[test] fn detected_override_installs_with_it_alone() { - // The dreamcli CI bug: bun + deno detected, RUNNER_PM=bun set — + // The dreamcli CI bug: bun + deno detected, RUNNER_PM=bun set, // deno must not install (and must not write deno.lock). let ctx = context(vec![PackageManager::Bun, PackageManager::Deno]); let overrides = override_pm(PackageManager::Bun, OverrideOrigin::EnvVar); @@ -583,29 +808,6 @@ mod tests { assert!(msg.contains("--pm"), "should name the flag: {msg}"); } - #[test] - fn still_colliding_clears_once_filtered_to_single_writer() { - use super::still_colliding; - use crate::types::DetectionWarning; - - let warning = DetectionWarning::InstallDirCollision { - dir: "node_modules", - pms: vec![PackageManager::Bun, PackageManager::Deno], - }; - // Restricting install to bun alone resolves the collision. - assert!(still_colliding(&warning, &[PackageManager::Bun]).is_none()); - // Both still selected → still a collision (reduced set unchanged). - let reduced = still_colliding(&warning, &[PackageManager::Bun, PackageManager::Deno]) - .expect("two writers still collide"); - match reduced { - DetectionWarning::InstallDirCollision { dir, pms } => { - assert_eq!(dir, "node_modules"); - assert_eq!(pms, vec![PackageManager::Bun, PackageManager::Deno]); - } - other => panic!("expected collision, got {other:?}"), - } - } - #[test] fn install_pms_allowlist_filters_to_listed_detected_pms() { // The reported case: bun + deno + cargo detected, allowlist = [bun] @@ -623,6 +825,174 @@ mod tests { assert_eq!(pms, vec![PackageManager::Bun]); } + /// bun + deno both writing `node_modules`, which is dreamcli's shape. + fn colliding_context() -> ProjectContext { + let mut ctx = context(vec![PackageManager::Bun, PackageManager::Deno]); + ctx.install_dirs = vec![InstallDir { + dir: "node_modules", + writers: vec![PackageManager::Bun, PackageManager::Deno], + }]; + ctx + } + + #[test] + fn colliding_writers_resolve_to_one_installer_by_default() { + let ctx = colliding_context(); + let plan = plan_install(&ctx, &ResolutionOverrides::default()).expect("plan"); + + assert_eq!(plan.pms, vec![PackageManager::Bun], "deno must not install"); + assert_eq!( + plan.shadowed, + vec![Shadowed { + loser: PackageManager::Deno, + winner: PackageManager::Bun, + dir: "node_modules", + }], + ); + assert!(plan.collisions.is_empty(), "resolved, so nothing to warn"); + } + + #[test] + fn ecosystem_pm_override_hands_the_tree_to_deno() { + // `[pm].node = "deno"` picks deno for package.json scripts; the same + // decision decides who owns node_modules, so bun is the one shadowed. + let ctx = colliding_context(); + let mut overrides = ResolutionOverrides::default(); + overrides.pm_by_ecosystem.insert( + Ecosystem::Deno, + PmOverride { + pm: PackageManager::Deno, + origin: OverrideOrigin::ConfigFile { + path: PathBuf::from("/tmp/test/runner.toml"), + }, + }, + ); + + let plan = plan_install(&ctx, &overrides).expect("plan"); + + assert_eq!(plan.pms, vec![PackageManager::Deno]); + assert_eq!( + plan.shadowed, + vec![Shadowed { + loser: PackageManager::Bun, + winner: PackageManager::Deno, + dir: "node_modules", + }], + ); + } + + #[test] + fn naming_both_writers_runs_both_serialized_with_one_warning() { + let ctx = colliding_context(); + let overrides = ResolutionOverrides { + install_pms: vec![PackageManager::Bun, PackageManager::Deno], + ..Default::default() + }; + + let plan = plan_install(&ctx, &overrides).expect("plan"); + + assert_eq!(plan.pms, vec![PackageManager::Bun, PackageManager::Deno]); + assert!(plan.shadowed.is_empty(), "consent means nothing is dropped"); + assert_eq!( + plan.collisions, + vec![CollisionDir { + dir: "node_modules", + writers: vec![PackageManager::Bun, PackageManager::Deno], + }], + ); + assert_eq!( + install_lanes(&plan), + vec![vec![PackageManager::Bun, PackageManager::Deno]], + "consented writers still must not race over one tree", + ); + } + + #[test] + fn on_collision_error_refuses_to_pick() { + let ctx = colliding_context(); + let overrides = ResolutionOverrides { + on_collision: CollisionPolicy::Error, + ..Default::default() + }; + + let err = plan_install(&ctx, &overrides).expect_err("must refuse"); + + assert!(matches!(err, ResolveError::InstallDirCollision { .. })); + let msg = format!("{err}"); + assert!(msg.contains("node_modules"), "msg: {msg}"); + assert!(msg.contains("[install].pms"), "msg: {msg}"); + } + + #[test] + fn on_collision_error_refuses_even_when_both_were_named() { + // The strict CI guard means "never two writers on one tree", so an + // explicit allowlist doesn't buy its way past it. + let ctx = colliding_context(); + let overrides = ResolutionOverrides { + install_pms: vec![PackageManager::Bun, PackageManager::Deno], + on_collision: CollisionPolicy::Error, + ..Default::default() + }; + + let err = plan_install(&ctx, &overrides).expect_err("must refuse"); + assert!(matches!(err, ResolveError::InstallDirCollision { .. })); + } + + #[test] + fn pm_override_leaves_one_writer_and_nothing_to_say() { + let ctx = colliding_context(); + let overrides = override_pm(PackageManager::Bun, OverrideOrigin::EnvVar); + + let plan = plan_install(&ctx, &overrides).expect("plan"); + + assert_eq!(plan.pms, vec![PackageManager::Bun]); + assert!(plan.collisions.is_empty()); + assert!( + plan.shadowed.is_empty(), + "nothing was dropped: deno was never in the install set to begin with" + ); + } + + #[test] + fn a_lone_writer_plans_clean() { + let mut ctx = context(vec![PackageManager::Bun, PackageManager::Cargo]); + ctx.install_dirs = vec![InstallDir { + dir: "node_modules", + writers: vec![PackageManager::Bun], + }]; + + let plan = plan_install(&ctx, &ResolutionOverrides::default()).expect("plan"); + + assert_eq!(plan.pms, vec![PackageManager::Bun, PackageManager::Cargo]); + assert!(plan.collisions.is_empty()); + assert!(plan.shadowed.is_empty()); + } + + #[test] + fn lanes_serialize_shared_writers_and_leave_the_rest_parallel() { + let plan = InstallPlan { + pms: vec![ + PackageManager::Bun, + PackageManager::Cargo, + PackageManager::Deno, + ], + shadowed: Vec::new(), + collisions: vec![CollisionDir { + dir: "node_modules", + writers: vec![PackageManager::Bun, PackageManager::Deno], + }], + }; + + assert_eq!( + install_lanes(&plan), + vec![ + vec![PackageManager::Bun, PackageManager::Deno], + vec![PackageManager::Cargo], + ], + "bun and deno share one lane and run in order; cargo runs alongside", + ); + } + #[test] fn install_pms_allowlist_preserves_detection_order() { let ctx = context(vec![ @@ -630,7 +1000,7 @@ mod tests { PackageManager::Cargo, PackageManager::Uv, ]); - // Listed out of detection order — output still follows detection. + // Listed out of detection order, output still follows detection. let overrides = ResolutionOverrides { install_pms: vec![PackageManager::Uv, PackageManager::Bun], ..Default::default() @@ -816,12 +1186,12 @@ mod tests { #[test] fn deny_is_noop_for_default_deny_and_unsupported_managers() { - // deno already denies by default — no flag added. + // deno already denies by default, no flag added. assert_eq!( install_argv(PackageManager::Deno, ScriptDirective::Deny), ["install"] ); - // cargo has no toggle — the deny is reported elsewhere, command unchanged. + // cargo has no toggle; the deny is reported elsewhere, command unchanged. assert_eq!( install_argv(PackageManager::Cargo, ScriptDirective::Deny), ["fetch"] @@ -843,7 +1213,7 @@ mod tests { #[test] fn force_on_is_noop_for_already_runs_and_unforceable_managers() { - // composer/cargo run scripts by default — force-on changes nothing. + // composer/cargo run scripts by default; force-on changes nothing. assert_eq!( install_argv(PackageManager::Composer, ScriptDirective::ForceOn), ["install"] @@ -852,7 +1222,7 @@ mod tests { install_argv(PackageManager::Cargo, ScriptDirective::ForceOn), ["fetch"] ); - // bun/pnpm gate dependency builds behind a manifest allowlist — no flag; + // bun/pnpm gate dependency builds behind a manifest allowlist, no flag; // the request is disclosed by warn_unsupported_script_policy instead. assert_eq!( install_argv(PackageManager::Bun, ScriptDirective::ForceOn), diff --git a/src/cmd/list.rs b/src/cmd/list.rs index 2b0bfa9e..719865ac 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -1,4 +1,4 @@ -//! `runner list` — display available tasks from all detected sources. +//! `runner list`, display available tasks from all detected sources. use std::collections::HashSet; use std::fmt::Write as _; @@ -38,7 +38,7 @@ pub(crate) fn list( Some(label) => Some(TaskSource::from_label(label).ok_or_else(|| { let expected = expected_source_labels(); anyhow!( - "--source {label:?}: unknown source label (expected one of: {expected} — legacy \ + "--source {label:?}: unknown source label (expected one of: {expected}, legacy \ filename forms like justfile/bacon.toml/Makefile are also accepted)", ) })?), @@ -68,7 +68,7 @@ pub(crate) fn list( } else if filtered.is_empty() { println!("{}", "No tasks found.".dimmed()); } else { - // `runner list` is an explicit request for the task list — + // `runner list` is an explicit request for the task list, // always full detail, never collapse. The height-adaptive // compact path is reserved for the bare `runner` / `runner // info` glance view (see `print_tasks_grouped`). @@ -109,7 +109,7 @@ fn select_render_mode(tasks: &[&Task], reserved_rows: usize) -> RenderMode { /// `reserved_rows` is output the caller has already emitted (or will /// emit) above the task list and that therefore eats into the visible -/// budget — e.g. the `runner` info banner (version line, Package +/// budget, e.g. the `runner` info banner (version line, Package /// Managers / Task Runners / Node / Monorepo rows, blank separators). /// `runner list` has no banner and passes `0`. The `+ 2` is a fixed /// allowance for the shell prompt that reappears after rendering plus a @@ -136,7 +136,7 @@ const fn predicted_rich_rows(tasks: &[&Task]) -> usize { /// Print tasks grouped by [`TaskSource`], collapsing to compact mode /// when the rich form would overflow the terminal. /// -/// Operates over a borrowed task slice + the project root — the renderer +/// Operates over a borrowed task slice + the project root; the renderer /// never reads other [`ProjectContext`] fields, so callers that already /// have a filtered task list pass the slice directly instead of forging /// a synthetic context. @@ -152,8 +152,8 @@ pub(super) fn print_tasks_grouped(tasks: &[&Task], root: &Path, reserved_rows: u } /// Print duplicate-name conflicts beneath the task list so a shadowed -/// task — one the bare-name lookup silently will *not* run (e.g. `cargo -/// run` losing to `just run`) — doesn't go unnoticed. Resolution uses the +/// task, one the bare-name lookup silently will *not* run (e.g. `cargo +/// run` losing to `just run`), doesn't go unnoticed. Resolution uses the /// same precedence as `runner run`, so the named winner is what actually /// executes. No output when there are no conflicts. pub(super) fn print_conflicts(ctx: &ProjectContext, overrides: &ResolutionOverrides) { @@ -203,7 +203,7 @@ fn format_conflicts( let count = conflicts.len(); let header = format!( - "{count} name conflict{} — `runner run ` picks one source:", + "{count} name conflict{}; `runner run ` picks one source:", if count == 1 { "" } else { "s" } ); let mut out = String::from("\n"); @@ -536,7 +536,7 @@ fn pad_visible(value: &str, width: usize) -> String { } fn source_label(source: TaskSource, root: &Path, stdout_is_terminal: bool) -> String { - // Display text is always the canonical `TaskSource::label()` — using + // Display text is always the canonical `TaskSource::label()`; using // `path.file_name()` instead collapses any source whose backing file // happens to be named `config.toml` (cargo, uv, pip, rust-toolchain, // mise variants, …) into an ambiguous single column. The OSC8 link @@ -698,6 +698,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), }; @@ -743,7 +744,7 @@ mod tests { #[test] fn source_label_uses_canonical_source_label_regardless_of_filename_variant() { - // The displayed text is always `TaskSource::label()` — never + // The displayed text is always `TaskSource::label()`, never // the resolved manifest's filename. Mixing in `file_name()` // would collapse the many sources whose config happens to // be named `config.toml` (cargo, uv, pip, mise variants, …) @@ -810,6 +811,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } diff --git a/src/cmd/lsp/analysis.rs b/src/cmd/lsp/analysis.rs index 6762b06d..b749863c 100644 --- a/src/cmd/lsp/analysis.rs +++ b/src/cmd/lsp/analysis.rs @@ -2,7 +2,7 @@ //! //! A small, TOML-aware (not TOML-complete) reading of the line under the cursor //! plus the nearest `[section]` header above it. Enough to answer "what section -//! am I in, and am I on a key or a value?" — which drives both hover lookups and +//! am I in, and am I on a key or a value?", which drives both hover lookups and //! completion candidate sets without a full document parse. use std::collections::BTreeMap; @@ -47,7 +47,7 @@ struct Cursor { shape: LineShape, } -/// Byte offset of the `#` that starts a comment on `line`, if any — the +/// Byte offset of the `#` that starts a comment on `line`, if any, the /// first `#` outside a `"`/`'` string literal. fn comment_start(line: &str) -> Option { let (mut in_basic, mut in_literal) = (false, false); @@ -242,7 +242,7 @@ pub(super) fn completion( /// Completion on the key side of a line. In `[tasks.overrides]` (or after /// `overrides.` in `[tasks]`) the keys are the project's own task names, so /// they complete from task discovery over the document's directory; any -/// other dotted key completes nothing — TOML reads it as a key *path*, and +/// other dotted key completes nothing: TOML reads it as a key *path*, and /// no other section has enumerable sub-keys. The typed token is replaced /// via an explicit text edit so a client can only ever substitute it, never /// append to it (a stale list left open after a backspace would otherwise @@ -404,7 +404,7 @@ fn section_items(schema: &SchemaIndex, bracketed: bool) -> Vec { } /// Sub-table completion under `parent` (e.g. `overrides` for `[tasks.`). -/// A parent with no sub-tables completes nothing — the top-level list would +/// A parent with no sub-tables completes nothing: the top-level list would /// only mint invalid `[parent.section]` paths. fn subtable_items(schema: &SchemaIndex, parent: &str) -> Vec { let prefix = format!("{parent}."); @@ -526,7 +526,7 @@ fn value_items( let field = schema.section(section).and_then(|s| s.fields.get(key)); // For a sequence-typed field with no `[` typed yet, wrap the first // element so accepting a completion yields valid TOML. Inside an open - // string literal the quotes (and brackets) are already typed — insert + // string literal the quotes (and brackets) are already typed, so insert // the bare word. let wrap = !in_array && !in_string && field.is_some_and(|f| f.field_type == FieldType::Array); if let Some(field) = field @@ -1122,7 +1122,7 @@ mod tests { #[test] fn value_completion_inside_an_open_string_stays_bare() { let schema = SchemaIndex::build(); - // `prefer = ["ba` — the quote is already typed (and the client may + // `prefer = ["ba`, the quote is already typed (and the client may // auto-pair the closer); inserting a quoted value would double it. let text = "[tasks]\nprefer = [\"ba\n"; let items = completion( diff --git a/src/cmd/lsp/diagnostics.rs b/src/cmd/lsp/diagnostics.rs index 2aa15885..ab262c3b 100644 --- a/src/cmd/lsp/diagnostics.rs +++ b/src/cmd/lsp/diagnostics.rs @@ -1,7 +1,7 @@ //! Diagnostics for a `runner.toml` buffer. //! -//! Runs exactly the checks `runner config validate` runs — TOML parse, unknown -//! keys, the deprecation nudges, and the resolver's field/policy validation — +//! Runs exactly the checks `runner config validate` runs, TOML parse, unknown +//! keys, the deprecation nudges, and the resolver's field/policy validation, //! against in-memory text, mapping each finding to an editor range. The //! validation logic itself is reused verbatim from [`crate::config`] and //! [`crate::resolver`]; only the range-anchoring is LSP-specific. @@ -31,7 +31,7 @@ pub(super) fn compute(text: &str, index: &LineIndex) -> Vec { out.push(warning_diagnostic(text, index, &warning)); } - // Deserialize from the text, not the parsed `value` — the text-based + // Deserialize from the text, not the parsed `value`: the text-based // deserializer spans a wrong-typed known field, so the diagnostic can // point at the offending value instead of line one. let config: RunnerConfig = match toml::from_str(text) { diff --git a/src/cmd/lsp/mod.rs b/src/cmd/lsp/mod.rs index 77afd640..354682ca 100644 --- a/src/cmd/lsp/mod.rs +++ b/src/cmd/lsp/mod.rs @@ -1,4 +1,4 @@ -//! `runner lsp` — a Language Server for `runner.toml`. +//! `runner lsp`, a Language Server for `runner.toml`. //! //! Speaks LSP over stdio and provides three editor features, each reusing the //! same internals the CLI does: @@ -10,7 +10,7 @@ //! and the runner/package-manager/source label vocabulary). //! //! The server is deliberately small and synchronous (one document at a time, -//! full-text sync) — runner.toml files are tiny, so there is no need for +//! full-text sync); runner.toml files are tiny, so there is no need for //! incremental sync or a background analysis thread. mod analysis; @@ -53,7 +53,7 @@ pub(crate) fn run() -> Result { server.serve(&connection)?; // Drop the connection before joining: the writer thread only terminates - // once its channel sender (held by `connection`) is gone — otherwise + // once its channel sender (held by `connection`) is gone; otherwise // `join` blocks forever. drop(connection); io_threads.join()?; @@ -243,8 +243,8 @@ fn document_dir(uri: &Uri) -> Option { .map(std::path::Path::to_path_buf) } -/// Whether `uri`'s basename is `runner.toml` or its dotfile form, at any depth — -/// each directory can hold its own config. +/// Whether `uri`'s basename is `runner.toml` or its dotfile form, at any depth, +/// because each directory can hold its own config. fn is_runner_toml(uri: &Uri) -> bool { uri.path().as_str().rsplit('/').next().is_some_and(|name| { name == CONFIG_FILENAME || name.strip_prefix('.') == Some(CONFIG_FILENAME) diff --git a/src/cmd/lsp/schema_index.rs b/src/cmd/lsp/schema_index.rs index 8cd7a4ff..de3ed938 100644 --- a/src/cmd/lsp/schema_index.rs +++ b/src/cmd/lsp/schema_index.rs @@ -34,7 +34,7 @@ pub(super) struct FieldDoc { /// The (single) JSON type shape a field schema declares. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum FieldType { - /// An object — a nested table writable as its own `[section.field]` + /// An object, a nested table writable as its own `[section.field]` /// header, e.g. `[tasks.overrides]`. Table, /// An array (a TOML sequence, e.g. `prefer = [...]`). diff --git a/src/cmd/man.rs b/src/cmd/man.rs index 85a96372..1c8bba19 100644 --- a/src/cmd/man.rs +++ b/src/cmd/man.rs @@ -1,4 +1,4 @@ -//! `runner man` — render roff man pages (feature `man`). +//! `runner man`, render roff man pages (feature `man`). use std::io::Write as _; use std::path::Path; @@ -72,7 +72,7 @@ fn render_command(cmd: Command, page_name: &str) -> Vec { strip_ansi(&raw) } -/// Drop ANSI escapes (CSI/OSC) that the CLI help strings bake in for color — +/// Drop ANSI escapes (CSI/OSC) that the CLI help strings bake in for color; /// they have no place in roff and would corrupt the page otherwise. fn strip_ansi(input: &[u8]) -> Vec { const ESC: u8 = 0x1b; @@ -151,7 +151,7 @@ mod tests { "missing {expected} page; got {names:?}" ); } - // `info` is `#[command(hide = true)]` — no page for it. + // `info` is `#[command(hide = true)]`, no page for it. assert!( !names.contains(&"runner-info"), "hidden `info` subcommand should not produce a page; got {names:?}" diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 69782ce8..dea51600 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -52,15 +52,19 @@ fn configure_command(command: &mut Command, dir: &Path, overrides: &ResolutionOv .stderr(Stdio::inherit()); // Mark children (and, by env inheritance, all descendants) when this // runner opens a GHA group around them, so a nested `runner`/`run` - // suppresses its own group — GHA groups don't nest. When we're already + // suppresses its own group. GHA groups don't nest. When we're already // nested the marker is in our inherited env, so children get it for free. if emits_group(overrides) { command.env(GROUP_ACTIVE_ENV, "1"); } + // Same idea for warnings: this process has already printed (or suppressed) + // everything detection found for `dir` by the time it spawns anything, so a + // nested runner over the same root stays quiet instead of repeating it. + command.env(WARNED_ROOT_ENV, dir); } /// Every existing `node_modules/.bin` from `dir` up to the filesystem -/// root, nearest first — the same set (and order) `npm run` exposes to +/// root, nearest first, the same set (and order) `npm run` exposes to /// `package.json` scripts. Levels without an installed `.bin` are /// skipped, so non-Node projects collect nothing and the whole /// augmentation becomes a no-op. @@ -95,7 +99,7 @@ fn prepend_node_bin_path(command: &mut Command, dir: &Path) { /// `bins` followed by the entries of `parent`, joined with the platform /// separator. `None` when joining fails (a bin dir embeds the separator -/// itself) — the caller leaves `PATH` untouched rather than corrupt it. +/// itself). The caller leaves `PATH` untouched rather than corrupt it. fn prepended_path(bins: &[PathBuf], parent: Option<&OsStr>) -> Option { let inherited = parent.map(std::env::split_paths).into_iter().flatten(); std::env::join_paths(bins.iter().cloned().chain(inherited)).ok() @@ -105,7 +109,7 @@ fn prepended_path(bins: &[PathBuf], parent: Option<&OsStr>) -> Option /// /// [`crate::tool::program::command`] resolves bare names against the /// parent `PATH`×`PATHEXT` before the bin dirs are prepended, and the -/// std child-`PATH` search only appends `.exe` at spawn time — so a +/// std child-`PATH` search only appends `.exe` at spawn time, so a /// `turbo.cmd`/`.ps1` shim living only under `node_modules/.bin` would /// fail to spawn. When a bare name resolves inside `bins`, rebuild the /// command around the absolute shim path, preserving args and env. @@ -175,15 +179,42 @@ pub(crate) fn exit_code(status: ExitStatus) -> i32 { /// transitively through intermediate processes; read into /// [`ResolutionOverrides::parent_group_open`]. /// -/// The contract is "a parent is *collecting* your output — don't open your own +/// The contract is "a parent is *collecting* your output, don't open your own /// group", which is slightly broader than "a literal `::group::` is open right /// now": it is also set in parallel-*streaming* mode, where the parent muxes /// child output behind a `[task] ` prefix instead of a group. Suppressing -/// nested grouping is correct in every case — a nested group would otherwise +/// nested grouping is correct in every case; a nested group would otherwise /// either nest-and-corrupt (grouped) or render as inert prefixed text /// (streaming). pub(crate) const GROUP_ACTIVE_ENV: &str = "RUNNER_GROUP_ACTIVE"; +/// Env marker carrying the project root whose detection warnings a parent +/// `runner`/`run` has already printed. A `package.json` script that calls +/// `runner` again (the common `"fmt": "runner run lint:fix fmt:dprint"` shape) +/// otherwise repeats every warning at every level. +pub(crate) const WARNED_ROOT_ENV: &str = "RUNNER_WARNED_ROOT"; + +/// Whether a parent runner already warned about this project. +/// +/// Keyed on the root, not merely on the marker's presence: a nested runner +/// pointed at a different directory (`--dir`, a monorepo package) has its own +/// detection to report, and must still report it. +pub(crate) fn parent_warned_about(root: &Path) -> bool { + let Some(marked) = std::env::var_os(WARNED_ROOT_ENV) else { + return false; + }; + same_root(Path::new(&marked), root) +} + +/// Compare two roots, preferring canonical paths so a symlinked or +/// `..`-laden spelling of one directory doesn't read as two. +fn same_root(marked: &Path, root: &Path) -> bool { + match (marked.canonicalize(), root.canonicalize()) { + (Ok(marked), Ok(root)) => marked == root, + _ => marked == root, + } +} + /// Whether to wrap a run in a GitHub Actions log group: only when the user /// hasn't opted out (`[github].group_output`) *and* we're under GitHub /// Actions, so `::group::` markers never leak into a normal terminal. @@ -220,7 +251,7 @@ pub(crate) fn emits_group(overrides: &ResolutionOverrides) -> bool { /// grouping is enabled (see [`should_group`]). /// /// The returned [`actions_rs::log::GroupGuard`] emits `::endgroup::` when it -/// is dropped — including on the `?` error path and on panic — so callers +/// is dropped, including on the `?` error path and on panic, so callers /// just bind it for the duration of the run. Returns `None` (emitting /// nothing) when grouping is off, which lets callers hold it unconditionally. fn task_group(overrides: &ResolutionOverrides, name: &str) -> Option { @@ -229,7 +260,7 @@ fn task_group(overrides: &ResolutionOverrides, name: &str) -> Option = Option<&'a mut std::collections::HashSet>; @@ -237,12 +268,18 @@ fn print_warnings(ctx: &ProjectContext, overrides: &ResolutionOverrides, sink: W print_warning_slice(&ctx.warnings, overrides, sink); } +/// Whether detection warnings stay unsaid: the user asked for silence, or a +/// parent runner already said them for this root. +const fn silenced(overrides: &ResolutionOverrides) -> bool { + overrides.no_warnings || overrides.parent_warned +} + fn print_warning_slice( warnings: &[DetectionWarning], overrides: &ResolutionOverrides, sink: WarningSink<'_>, ) { - if overrides.no_warnings { + if silenced(overrides) { return; } if let Some(set) = sink { @@ -260,13 +297,13 @@ fn print_warning_slice( /// executor after all per-task resolutions have populated the sink. /// /// Sorted by `Display` form before emission so output is stable across -/// runs — `HashSet` iteration order is unspecified, which made the +/// runs; `HashSet` iteration order is unspecified, which made the /// warning block jump around between invocations of the same chain. pub(crate) fn emit_collected_warnings( warnings: &std::collections::HashSet, overrides: &ResolutionOverrides, ) { - if overrides.no_warnings { + if silenced(overrides) { return; } let mut sorted: Vec<(String, &DetectionWarning)> = @@ -361,7 +398,7 @@ mod tests { fn group_emission_gates_on_nesting() { // Exercise the pure core directly (no live GITHUB_ACTIONS needed): the // nested flag is the load-bearing gate. Holding grouping on + under - // Actions, flipping parent_group_open flips the decision — proving the + // Actions, flipping parent_group_open flips the decision, proving the // nesting suppression actually fires (a tautological test that only // checked the env-false path would pass even without the gate). assert!( @@ -398,7 +435,7 @@ mod tests { let bins = node_bin_dirs(&member); - // `apps/` has no node_modules — levels without an installed + // `apps/` has no node_modules; levels without an installed // `.bin` are skipped, not invented. Entries past the temp root // (a stray `/tmp/node_modules`) are out of our control, so only // pin the leading order and that nothing else came from inside @@ -460,7 +497,7 @@ mod tests { // OS-level bare-name lookup must honor the PATH set on the // child Command (std documents this on `Command::new`). A // devDependency-style shim that exists only under the project's - // `node_modules/.bin` has to spawn — this is exactly the + // `node_modules/.bin` has to spawn; this is exactly the // "turbo.json task dies with ENOENT because turbo is only a // devDependency" report. let dir = TempDir::new("child-path-spawn"); @@ -488,7 +525,7 @@ mod tests { // `CreateProcessW` never consults PATHEXT and the std child-PATH // search only appends `.exe`, so a bare name backed only by a // `.cmd` shim in node_modules/.bin must be rebuilt around the - // absolute shim path — with args and env tweaks surviving. + // absolute shim path, with args and env tweaks surviving. let dir = TempDir::new("win-bin-shim"); let bin = dir.path().join("node_modules").join(".bin"); fs::create_dir_all(&bin).expect("bin dir should be created"); diff --git a/src/cmd/run.rs b/src/cmd/run.rs index 813bcd12..f463f861 100644 --- a/src/cmd/run.rs +++ b/src/cmd/run.rs @@ -1,19 +1,19 @@ -//! `runner run ` — resolve a task name to the right tool and execute +//! `runner run `, resolve a task name to the right tool and execute //! it. When no task matches, fall back to executing the target as an //! arbitrary command through the detected package manager (formerly `runner //! exec`). //! //! # Module layout //! -//! - [`qualify`] — `source:task` parsing, reversed-qualifier detection, +//! - [`qualify`], `source:task` parsing, reversed-qualifier detection, //! and the side-effect-free [`qualify::precheck_task`] used by chain //! mode to bail before any sibling task runs. -//! - [`select`] — picking the best [`crate::types::Task`] candidate when +//! - [`select`], picking the best [`crate::types::Task`] candidate when //! a name matches multiple sources. The ranking key (priority, depth, //! display order, alias-ness) is split into individual `pub(crate)` //! helpers so [`crate::cmd::why`] can render the same key the dispatcher //! used. -//! - [`dispatch`] — turning a task token into a fully-configured +//! - [`dispatch`], turning a task token into a fully-configured //! [`std::process::Command`]: warning emission, the resolver chain, //! bun-test special case, PM-exec fallback, and per-source `run_cmd` //! selection. @@ -162,7 +162,7 @@ mod tests { #[test] fn detect_reversed_qualifier_catches_task_colon_source() { - // `lint:cargo` has the qualifier inverted — caller should bail + // `lint:cargo` has the qualifier inverted; caller should bail // with `did you mean "cargo:lint"?` instead of falling through // to PM-exec and spawning a binary named `lint:cargo`. let got = detect_reversed_qualifier("lint:cargo"); @@ -171,7 +171,7 @@ mod tests { #[test] fn detect_reversed_qualifier_returns_none_for_correct_syntax() { - // Correct ordering — the prefix branch (`parse_qualified_task`) + // Correct ordering, the prefix branch (`parse_qualified_task`) // handles this; the reversed-detector must not fire. assert!(detect_reversed_qualifier("cargo:lint").is_none()); // Plain name, no colon. @@ -208,7 +208,7 @@ mod tests { #[test] fn reversed_qualifier_fast_fail_does_not_block_real_tasks() { // The fast-fail in `resolve_dispatch` is gated by - // `restricted.is_empty()` — a real task whose name happens to + // `restricted.is_empty()`; a real task whose name happens to // match the `task:source` shape must still dispatch. // // We mirror the dispatch lookup directly: `parse_qualified_task` @@ -231,6 +231,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), }; @@ -358,6 +359,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), }; @@ -372,7 +374,7 @@ mod tests { // never matches it, so without the subdir-fallback the depth // would collapse to `usize::MAX` and any root-level source // (`bacon.toml`, `Makefile`, …) would win every tiebreak by - // default — robbing `display_order` of the tie-break it was + // default, robbing `display_order` of the tie-break it was // designed to perform. let dir = TempDir::new("source-depth-subdirectory"); let cargo_dir = dir.path().join(".cargo"); @@ -391,6 +393,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), }; @@ -449,6 +452,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), }; @@ -501,6 +505,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), }; @@ -520,6 +525,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } diff --git a/src/cmd/run/dispatch.rs b/src/cmd/run/dispatch.rs index cd2fb54e..86a012b2 100644 --- a/src/cmd/run/dispatch.rs +++ b/src/cmd/run/dispatch.rs @@ -129,9 +129,9 @@ fn decide_deno_self_exec( /// Resolve `task` to a fully-configured [`Command`] without spawning it. /// -/// Walks the same cascade for every caller — warning emission, qualified +/// Walks the same cascade for every caller, warning emission, qualified /// vs unqualified lookup, runner constraint check, resolver chain, -/// bun-test special case, PM-exec fallback, or a normal task entry — +/// bun-test special case, PM-exec fallback, or a normal task entry, /// and returns a [`Command`] whose working directory + env have already /// been set via [`crate::cmd::configure_command`]. Callers attach stdio + /// `.status()` / `.spawn()` according to their needs. @@ -181,7 +181,7 @@ pub(super) fn resolve_dispatch( // candidate that isn't under one of the allowed sources is treated // as non-existent. A qualifier (`runner.json:task`) is the user // narrowing *to* a source explicitly and outranks the runner - // constraint — the qualified branch below applies its own match. + // constraint; the qualified branch below applies its own match. let restricted: Vec<_> = if qualifier.is_some() { found.clone() } else if let Some(allowed) = allowed_runner_sources(overrides) { @@ -197,7 +197,7 @@ pub(super) fn resolve_dispatch( if restricted.is_empty() { // Restrictive override active but no candidate matched: hard // error per the resolved design decision (explicit intent - // never silently downgrades). Skipped for qualified misses — + // never silently downgrades). Skipped for qualified misses, // the qualifier (`justfile:foo`) is stronger user intent than // `--runner` / `[task_runner].prefer`, so report the qualified // miss directly instead of surfacing a runner-constraint error @@ -217,14 +217,14 @@ pub(super) fn resolve_dispatch( } // Local file without an explicit prefix: a token that names a - // runnable file under the project root — a bare name (`main.ts`, - // `build.sh`) or a relative path with a separator (`bin/tool`) — runs + // runnable file under the project root, a bare name (`main.ts`, + // `build.sh`) or a relative path with a separator (`bin/tool`), runs // as that file. Sits *after* the runner-constraint hard error (an // explicit `--runner` never silently downgrades to a coincidental // file) but *before* PM resolution, so a local file still runs when // node-PM resolution would hard-error for reasons unrelated to it (a // strict devEngines/`packageManager` mismatch, an incompatible - // `--pm`) — running `main.ts` via its runtime doesn't need the + // `--pm`); running `main.ts` via its runtime doesn't need the // package.json PM. Tasks already matched above (`restricted` is empty // here), so this never shadows a same-named task, and `bunx`/`npx` // never sees a local file. @@ -266,7 +266,7 @@ pub(super) fn resolve_dispatch( }; // Qualified miss (colon or FQN syntax): the qualifier is explicit - // task-lookup intent, so error here — never fall through to + // task-lookup intent, so error here, never fall through to // PM-exec, which would hand the token to bunx/npx as a package // spec and resolve it off the network. return Err(qualified_miss_error(ctx, missed_source, task_name)); @@ -370,8 +370,8 @@ impl ResolvedPythonPm { /// `package.json` `test` script: forward to `bun test`. /// /// `resolved_pm` is the verdict from the full resolver chain, so all -/// signals — `--pm`, `RUNNER_PM`, `runner.toml`, `packageManager`, -/// `devEngines.packageManager`, lockfile, PATH probe — get a vote. +/// signals, `--pm`, `RUNNER_PM`, `runner.toml`, `packageManager`, +/// `devEngines.packageManager`, lockfile, PATH probe, get a vote. /// Fires only when the resolver landed on Bun. pub(super) fn should_use_bun_test_fallback( ctx: &ProjectContext, @@ -499,6 +499,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } @@ -579,7 +580,7 @@ mod tests { #[test] fn resolve_dispatch_accepts_v3_cargo_alias_fqn() { // Schema v3 labels cargo alias tasks `cargo-alias`, so doctor/why - // print `root:cargo-alias#` — that exact string must run. + // print `root:cargo-alias#`; that exact string must run. let mut ctx = context(); ctx.tasks.push(Task { name: "b".to_string(), @@ -625,7 +626,7 @@ mod tests { #[test] fn resolve_dispatch_github_spec_still_reaches_pm_exec() { - // `user/repo#ref` is a legit bunx/npx package spec — its prefix + // `user/repo#ref` is a legit bunx/npx package spec; its prefix // is not a source label, so it must keep flowing to PM-exec. let dispatch = resolve_dispatch( &context(), diff --git a/src/cmd/run/local_file.rs b/src/cmd/run/local_file.rs index 60c8ff18..e7d13222 100644 --- a/src/cmd/run/local_file.rs +++ b/src/cmd/run/local_file.rs @@ -1,6 +1,6 @@ //! Local-file execution for `run `. //! -//! A token that points at a local file should be *run as that file* — never +//! A token that points at a local file should be *run as that file*, never //! handed to a package manager's package-exec primitive (`bunx`/`npx`/ //! `pnpm dlx`/`deno x`/`uvx`), which would resolve a local path as a remote //! package spec and fail with a registry 404 or a `git clone` error. @@ -13,8 +13,8 @@ //! 1. **Directly executable file** (a native binary, or a script whose `#!` //! line the kernel can honor) → spawned directly (`Command::new(path)`). //! A recognized *source* file that merely carries the exec bit but has no -//! `#!` line is **not** run this way — `execve` cannot run shebang-less -//! text (it returns `ENOEXEC`) — it falls to outcome 3 instead. +//! `#!` line is **not** run this way; `execve` cannot run shebang-less +//! text (it returns `ENOEXEC`); it falls to outcome 3 instead. //! 2. **Non-executable file with a `#!` shebang** → the interpreter is //! parsed (including `#!/usr/bin/env -S `) and the file is //! run through it. @@ -53,13 +53,13 @@ pub(super) struct LocalDispatch { /// target) wins first and a built artifact on disk cannot silently shadow it. /// /// Returns: -/// - `Ok(None)` — `token` has no local prefix (a bare name, a relative +/// - `Ok(None)`, `token` has no local prefix (a bare name, a relative /// `bin/tool`, an existing directory, or a remote spec like `@scope/pkg` /// or `github.com/owner/tool`); the caller continues normal task / /// PM-exec resolution. -/// - `Ok(Some(_))` — `token` resolves to a runnable file; the caller spawns +/// - `Ok(Some(_))`, `token` resolves to a runnable file; the caller spawns /// the returned command instead of touching any package manager. -/// - `Err(_)` — `token` is unambiguously a local path that cannot be run +/// - `Err(_)`, `token` is unambiguously a local path that cannot be run /// (a missing file behind an explicit `./`/`/`/`~` prefix, or a file of /// unrecognized type); surfaced as a clear error rather than a 404. pub(super) fn try_path_token( @@ -75,9 +75,9 @@ pub(super) fn try_path_token( dispatch_for_path(ctx, overrides, token, &path, args) } -/// Try to interpret a `token` *without* an explicit local-path prefix — a +/// Try to interpret a `token` *without* an explicit local-path prefix, a /// bare name (`main.ts`) or a relative path bearing a separator -/// (`bin/tool`) — as a runnable file under the project root (`ctx.root`). +/// (`bin/tool`), as a runnable file under the project root (`ctx.root`). /// Used as a /// fallback *after* task lookup misses but before the PM-exec fallback, so a /// matching task always wins first (a bare name or a `make bin/tool` target @@ -92,11 +92,11 @@ pub(super) fn try_path_token( /// directory) is left to the PM-exec fallback (a real package spec). /// /// Returns: -/// - `Ok(None)` — `token` carries a local prefix, names nothing on disk, or +/// - `Ok(None)`, `token` carries a local prefix, names nothing on disk, or /// resolves to a non-file (a directory like `./cmd/foo`); the caller /// continues to the PM-exec fallback. -/// - `Ok(Some(_))` — `token` resolves to a runnable file; the caller spawns it. -/// - `Err(_)` — `token` resolves to an existing regular file the chosen runtime +/// - `Ok(Some(_))`, `token` resolves to a runnable file; the caller spawns it. +/// - `Err(_)`, `token` resolves to an existing regular file the chosen runtime /// can't run (an unsupported type, no shebang and not executable, or a `.tsx` /// in a node-only project). Surfaces the same clear error the explicit `./` /// form raises, never a swallowed `None` that mis-routes an existing file @@ -132,7 +132,7 @@ fn bare_file_in( } // The token names an existing regular file, so it is a local file: run it, // and if it can't be run (unsupported type, no shebang and not executable, - // or a `.tsx` resolved to Node) surface that as the clear local-file error — + // or a `.tsx` resolved to Node) surface that as the clear local-file error, // exactly what the explicit `./file` form does (`dispatch_for_path` -> // `build_command?`). Never swallow it into a `None` that falls through to // `pnpm exec`/`npx ` and a registry 404 (#69). Missing tokens and @@ -154,7 +154,7 @@ fn dispatch_for_path( let Ok(meta) = fs::metadata(path) else { // The path resolves to nothing on disk. An explicit local path // (`./x`, `../x`, `/x`, `~/x`, `C:\x`) clearly means a file that - // is not there — report it plainly. Anything else (`@scope/pkg`, + // is not there; report it plainly. Anything else (`@scope/pkg`, // `github.com/owner/tool`) falls through so the PM-exec / Go // import-path fallback can treat it as a remote spec. if has_local_prefix(token) { @@ -187,7 +187,7 @@ fn build_command( // text that `execve` cannot run) from a genuine self-executable script // or native binary. An execute-only file (mode 0111) cannot be opened for // read, so the probe simply yields `None` (no shebang to honor) rather - // than aborting — the kernel can still `execve` such a binary. + // than aborting; the kernel can still `execve` such a binary. let shebang = read_shebang(path); let routing = routing_for_extension(ctx, overrides, path); @@ -197,7 +197,7 @@ fn build_command( // shebang-less source file with the exec bit (common on // vfat/exfat/ntfs-3g mounts that report mode 0777 for every file) // would otherwise hit `execve`, fail `ENOEXEC`, and never reach - // bun/deno/node/uv/python/go — so route it to the runtime (outcome 3). + // bun/deno/node/uv/python/go, so route it to the runtime (outcome 3). if is_directly_executable(path, meta) && (shebang.is_some() || matches!(routing, SourceRouting::Unrecognized)) { @@ -206,13 +206,13 @@ fn build_command( return Ok((String::from("exec"), command)); } - // 2. A `#!` shebang names the interpreter explicitly — honor it even + // 2. A `#!` shebang names the interpreter explicitly; honor it even // without the executable bit (and on Windows, which has none). if let Some(shebang) = shebang { return Ok(shebang_command(&shebang, path, args)); } - // 3. A recognized source extension runs via the project runtime — except + // 3. A recognized source extension runs via the project runtime, except // a `.jsx`/`.tsx` file resolved to Node, which Node cannot execute // (no JSX transform; type-stripping covers only `.ts`/`.mts`/`.cts`). // Erroring is honest; `node app.tsx` would be guaranteed-broken. @@ -260,7 +260,7 @@ pub(super) fn has_local_prefix(token: &str) -> bool { } /// Whether `token` starts with a Windows drive-letter root (`C:\` / `C:/`). -fn is_windows_drive_abs(token: &str) -> bool { +const fn is_windows_drive_abs(token: &str) -> bool { let bytes = token.as_bytes(); bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() @@ -269,7 +269,7 @@ fn is_windows_drive_abs(token: &str) -> bool { } /// Resolve `token` to an absolute path: expand a leading `~`, then anchor a -/// relative path on `base` — the detected project root (`ctx.root`), which +/// relative path on `base`, the detected project root (`ctx.root`), which /// also becomes the spawned child's working directory and is what task /// detection runs against. Anchoring here (rather than on the live process /// cwd) keeps local-file lookup consistent with every other dispatch step, @@ -328,7 +328,7 @@ struct Shebang { /// /// I/O failure is non-fatal and yields `None`: by the time this runs the path /// is already a regular file (`fs::metadata` succeeded), so a failed -/// `open`/`read` means an execute-only file (Unix mode 0111 — a legitimate +/// `open`/`read` means an execute-only file (Unix mode 0111, a legitimate /// mode for compiled binaries) that the kernel can `execve` but cannot be /// opened `O_RDONLY` (EACCES). "No shebang we can honor" → defer to the exec /// bit / extension rather than hard-failing the whole dispatch. @@ -359,10 +359,10 @@ fn read_shebang(path: &Path) -> Option { /// Parse a `#!` line into the interpreter program and its arguments. /// /// The kernel hands the interpreter exactly one argument: everything after the -/// first whitespace, internal spaces intact — it does *not* whitespace-split. +/// first whitespace, internal spaces intact; it does *not* whitespace-split. /// So a direct interpreter and a plain `env` both keep the remainder as a /// single argument. Only `env -S` / `--split-string` re-splits that argument, -/// using env's own quote-aware parser ([`split_env_string`]) — never a naive +/// using env's own quote-aware parser ([`split_env_string`]), never a naive /// `split_whitespace`, which would tear `--allow-read="a b"` in two and invent /// argv for `#!/usr/bin/env python3 -O`. fn parse_shebang(line: &str) -> Option { @@ -381,7 +381,7 @@ fn parse_shebang(line: &str) -> Option { // --allow-read="a b"`) stay one token. Plain `env` (no `-S`) does NOT // split: the kernel's single argument is the program name, so // `#!/usr/bin/env python3 -O` resolves to the (bogus) program - // `"python3 -O"` exactly as the kernel would run it — `-S` is what adds + // `"python3 -O"` exactly as the kernel would run it; `-S` is what adds // the splitting. let split_form = rest .strip_prefix("--split-string=") @@ -417,7 +417,7 @@ fn single_arg(rest: &str) -> Vec { /// Split an `env -S` / `--split-string` argument the way GNU `env`'s /// split-string parser does for the cases shebangs actually use: ASCII /// whitespace separates words, while single quotes, double quotes, and a -/// backslash keep a run — embedded spaces and all — as one word. This +/// backslash keep a run, embedded spaces and all, as one word. This /// preserves quoted arguments (`--allow-read="a b"`) that `split_whitespace` /// would tear in two. The exotic `\t`/`\n`/`\c` escape sequences env also /// understands are vanishingly rare in shebangs and intentionally unmodeled. @@ -528,7 +528,7 @@ enum SourceRouting { /// execute JSX/TSX: Node has no JSX transform and type-strips only /// `.ts`/`.mts`/`.cts`. A clear error, never a broken `node app.tsx`. NodeCannotRunJsx, - /// Not a recognized source extension — defer to the exec bit / shebang + /// Not a recognized source extension, defer to the exec bit / shebang /// (a native binary) or, failing that, outcome 4's error. Unrecognized, } @@ -608,7 +608,7 @@ fn js_runtime_from_override(overrides: &ResolutionOverrides) -> Option /// Python runtime: `uv run` when uv is overridden or detected, otherwise the /// plain interpreter. A non-uv Python override (poetry/pipenv) uses the -/// plain interpreter — `uv run` would be wrong for those projects. +/// plain interpreter; `uv run` would be wrong for those projects. fn py_runtime(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> Runtime { let overridden = overrides.pm.as_ref().map(|over| over.pm).or_else(|| { overrides @@ -692,6 +692,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } @@ -776,8 +777,8 @@ mod tests { #[test] fn parse_shebang_plain_env_keeps_tail_as_single_argument() { - // Plain `env` (no `-S`) gets ONE argument from the kernel — the whole - // remainder — and treats it as the program name. It does not split on + // Plain `env` (no `-S`) gets ONE argument from the kernel, the whole + // remainder, and treats it as the program name. It does not split on // whitespace (that's what `-S` is for), so `env python3 -O` resolves to // the program `"python3 -O"`, matching how the kernel runs the file. let parsed = parse_shebang("#!/usr/bin/env python3 -O").expect("should parse"); @@ -793,7 +794,7 @@ mod tests { #[test] fn parse_shebang_direct_interpreter_keeps_tail_as_single_argument() { // A direct interpreter also receives a single kernel argument: the - // whole remainder, internal spaces intact — never re-split. + // whole remainder, internal spaces intact, never re-split. let parsed = parse_shebang("#!/bin/awk -f -v").expect("should parse"); assert_eq!(parsed.program, "/bin/awk"); assert_eq!(parsed.args, ["-f -v"]); @@ -1057,7 +1058,7 @@ mod tests { fn build_command_spawns_execute_only_binary_directly() { use std::os::unix::fs::PermissionsExt as _; - // A native binary at mode 0111 (execute-only — a legitimate mode): + // A native binary at mode 0111 (execute-only, a legitimate mode): // the kernel can `execve` it, but `open(O_RDONLY)` returns EACCES so // the shebang probe cannot read it. It must still spawn directly // (outcome 1) rather than hard-fail on the unreadable shebang read. @@ -1315,7 +1316,7 @@ mod tests { #[test] fn bare_file_existing_unsupported_file_errors() { // Once the token names an existing regular file it is a local file: an - // unsupported one (`data.bin` — no extension we run, no shebang, not + // unsupported one (`data.bin`, no extension we run, no shebang, not // executable) surfaces the clear local-file error instead of falling // through to `bunx data.bin` and a registry 404, matching the explicit // `./data.bin` form (#69). @@ -1333,7 +1334,7 @@ mod tests { fn bare_file_errors_on_jsx_in_node_project() { // Regression for #69: a bare (no `./` prefix) `app.tsx` that exists on // disk in a node-only project must surface the same `node cannot run` - // error the explicit `./app.tsx` form raises — never let `build_command`'s + // error the explicit `./app.tsx` form raises, never let `build_command`'s // error be swallowed into a `None` that falls through to `pnpm exec // app.tsx` / `npx app.tsx` and a registry 404. let dir = TempDir::new("bare-tsx-node"); @@ -1359,7 +1360,7 @@ mod tests { fn try_bare_file_declines_local_prefix_tokens() { // Prefix-bearing paths are the pre-task path branch's responsibility; // the after-miss fallback must decline them outright. A prefix-less - // relative path like `bin/tool` is NOT declined here — see + // relative path like `bin/tool` is NOT declined here. See // `bare_file_resolves_relative_separator_token`. let ctx = context(vec![PackageManager::Bun]); let defaults = ResolutionOverrides::default(); @@ -1377,7 +1378,7 @@ mod tests { fn try_path_token_declines_relative_separator_tokens() { // `bin/tool` carries a separator but no explicit local prefix, so the // pre-task path branch must decline it (returning `None` without - // touching the filesystem) — a matching `make bin/tool` task wins + // touching the filesystem); a matching `make bin/tool` task wins // first. Only an explicit `./bin/tool` outranks a task. let result = try_path_token( &context(vec![PackageManager::Bun]), @@ -1397,7 +1398,7 @@ mod tests { fn bare_file_resolves_relative_separator_token() { // The after-miss fallback accepts a prefix-less relative path that // carries a separator (`bin/main.ts`), resolving it under the base - // directory — so an unmatched make-style target name still runs as a + // directory, so an unmatched make-style target name still runs as a // local file once task lookup has missed. let dir = TempDir::new("bare-file-nested"); std::fs::create_dir(dir.path().join("bin")).expect("subdir should be created"); @@ -1421,7 +1422,7 @@ mod tests { #[test] fn try_bare_file_resolves_against_ctx_root() { // The bare-file fallback anchors on `ctx.root` (the detected project - // dir / `--dir` target), not the live process cwd — a `main.ts` under + // dir / `--dir` target), not the live process cwd; a `main.ts` under // the project root runs even when the shell cwd is elsewhere. This is // what stops a `--dir`-set run from missing the file and mis-routing // into the `bunx main.ts` 404 fallback (issue #69). @@ -1441,7 +1442,7 @@ mod tests { #[test] fn try_path_token_resolves_relative_prefix_against_ctx_root() { // An explicit `./main.ts` is joined onto `ctx.root`, so it resolves the - // project-root file regardless of the process cwd — consistent with + // project-root file regardless of the process cwd, consistent with // task detection and the spawned child's working directory. let dir = TempDir::new("path-root"); std::fs::write(dir.path().join("main.ts"), "console.log(1)\n") diff --git a/src/cmd/run/qualify.rs b/src/cmd/run/qualify.rs index f93621ac..b0b613da 100644 --- a/src/cmd/run/qualify.rs +++ b/src/cmd/run/qualify.rs @@ -3,7 +3,7 @@ //! Pure structural checks on a task string (the qualifier `source:task` //! syntax, reversed-qualifier detection, runner-constraint matching). //! No subprocess spawning, no `→` arrow printed, no resolver state -//! consulted — so the chain executor can run [`precheck_task`] on every +//! consulted, so the chain executor can run [`precheck_task`] on every //! item *before* any sibling dispatches. use std::collections::HashSet; @@ -30,7 +30,7 @@ pub(super) fn parse_qualified_task(input: &str) -> (Option, &str) { /// Parse the `#`-separated FQN form that `doctor --json` / `why --json` /// print as a task's identity: `root:#` (the `root:` scope /// prefix is optional on input). Returns `None` for anything whose part -/// before the `#` doesn't name a source — `user/repo#ref` package specs +/// before the `#` doesn't name a source. `user/repo#ref` package specs /// keep flowing to the PM-exec fallback untouched. pub(super) fn parse_fqn_task(input: &str) -> Option<(TaskSource, &str)> { let (prefix, name) = input.split_once('#')?; @@ -41,7 +41,7 @@ pub(super) fn parse_fqn_task(input: &str) -> Option<(TaskSource, &str)> { /// How a task token was interpreted by [`lookup_token`]. /// -/// A `Some` qualifier — whether from colon or FQN syntax — pins the +/// A `Some` qualifier, whether from colon or FQN syntax, pins the /// lookup to that source; the caller errors on a miss instead of /// falling through to PM-exec. That property is what keeps an FQN typo /// (`root:package.json#nope`) from being handed to bunx/npx as a @@ -102,7 +102,7 @@ pub(crate) fn lookup_token<'a>( /// The one spelling of a qualified miss, shared by dispatch and precheck /// so `run deno:x` and `run -p deno:x` fail identically. Appends a note -/// when a source's task list failed to load — a miss caused by a broken +/// when a source's task list failed to load; a miss caused by a broken /// `package.json` should say so instead of leaving the user chasing the /// task name. pub(super) fn qualified_miss_error( @@ -158,8 +158,8 @@ fn append_unreadable_note(ctx: &ProjectContext, msg: &mut String) { /// named after the user's typo. /// /// Matches only on the last colon so a hypothetical task name with -/// embedded colons (`foo:bar:cargo`) collapses to a single suffix check -/// — if `cargo` is the suffix, suggest `cargo:foo:bar`; otherwise let +/// embedded colons (`foo:bar:cargo`) collapses to a single suffix check. +/// If `cargo` is the suffix, suggest `cargo:foo:bar`; otherwise let /// the existing fallback path handle it. pub(super) fn detect_reversed_qualifier(input: &str) -> Option<(TaskSource, &str)> { let colon = input.rfind(':')?; @@ -174,15 +174,15 @@ pub(super) fn detect_reversed_qualifier(input: &str) -> Option<(TaskSource, &str /// expensive or stdio-visible work: no warnings emitted, no `→` arrow /// printed, no ` --version` probes. Used by chain mode to bail /// before running *any* sibling task when a later token is clearly -/// broken — otherwise a chain like `bb t lint:cargo` runs `bb` and +/// broken, otherwise a chain like `bb t lint:cargo` runs `bb` and /// `t` to completion before surfacing the typo at item 3. /// /// Returns `Ok(())` on: -/// - an explicit-prefix local path (`./gen.sh`, `~/x`) — dispatch runs it +/// - an explicit-prefix local path (`./gen.sh`, `~/x`): dispatch runs it /// as a file before any runner-constraint check, so precheck must too, /// - matched task (qualified or unqualified), /// - unmatched task whose dispatch would fall back to bun-test or -/// PM-exec — those paths require resolver state we deliberately +/// PM-exec: those paths require resolver state we deliberately /// skip here; they get their proper error at dispatch time. /// /// Returns `Err` on: @@ -196,7 +196,7 @@ pub(crate) fn precheck_task( ) -> Result<()> { // An explicit-prefix local path (`./gen.sh`, `~/x`, `/abs/x`) is dispatched // by `try_path_token` at the very top of `resolve_dispatch`, *before* the - // runner-constraint check — so an explicit path outranks task/runner + // runner-constraint check, so an explicit path outranks task/runner // resolution. Mirror that precedence here: without this escape hatch, an // active `--runner` / `[task_runner].prefer` constraint would make precheck // compute `found = []` and bail with a runner-constraint error, aborting a @@ -256,7 +256,7 @@ pub(crate) fn precheck_task( /// Compute the set of [`TaskSource`]s the user's runner constraint /// permits, or `None` when no constraint is active. /// -/// `--runner` / `RUNNER_RUNNER` is the strongest signal — only that +/// `--runner` / `RUNNER_RUNNER` is the strongest signal: only that /// runner's source is allowed. `[task_runner].prefer` is the next: /// every runner in the list is allowed, in listed order. Runners that /// don't map to a [`TaskSource`] (`nx`, `mise`) are dropped from the @@ -352,6 +352,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } @@ -397,7 +398,7 @@ mod tests { #[test] fn lookup_token_exact_name_wins_on_qualified_miss() { // A script literally named `deno:importsmap` was unreachable: - // `deno` parses as a source label, the lookup missed, dispatch + // `deno` parses as a source label, so the lookup missed and dispatch // fell to PM-exec (ts-x509 transcript). Exact full-name match // must win when the qualified lookup has no candidate. let mut ctx = context(); @@ -476,7 +477,7 @@ mod tests { fn precheck_passes_explicit_local_path_under_runner_constraint() { // An explicit-prefix local path is dispatched as a file by // `try_path_token` *before* the runner-constraint check, so precheck - // must wave it through too — otherwise a chain / install --parallel + // must wave it through too, otherwise a chain / install --parallel // under an active `[task_runner].prefer` aborts on a token that a // single `run ./gen.sh` executes fine. let overrides = ResolutionOverrides { @@ -511,7 +512,7 @@ mod tests { fn precheck_does_not_restrict_under_tasks_prefer() { // `[tasks].prefer` is rank-only: unlike the deprecated restrictive // `[task_runner].prefer`, a prefix-less miss under it must NOT fail - // precheck — nothing is hard-rejected, it only reorders. + // precheck; nothing is hard-rejected. It only reorders. let overrides = ResolutionOverrides { prefer_sources: vec![TaskSource::TurboJson], ..ResolutionOverrides::default() diff --git a/src/cmd/run/select.rs b/src/cmd/run/select.rs index 0ce3d0f4..2b13fc64 100644 --- a/src/cmd/run/select.rs +++ b/src/cmd/run/select.rs @@ -1,7 +1,7 @@ //! Source-selection logic: picking the best candidate when a task name matches multiple sources. //! //! The selector key is `(source_priority, source_depth, display_order, -//! is_alias)` — primary tier (Turbo > Package > others, plus prefer-list +//! is_alias)`, primary tier (Turbo > Package > others, plus prefer-list //! offset and the forced-PM source bias), then nearest-config tiebreak, //! then the source's canonical display order, then recipes-before-aliases. //! Each component is pure, exposed `pub(crate)` so `cmd::why` can show the @@ -12,7 +12,7 @@ //! front of a same-name conflict, most-native first, so `RUNNER_PM=deno run //! check` picks `deno:check` (a `deno task`) instead of running //! `package.json:check` *through* deno, and `--pm bun` pulls `package.json` -//! to the front the same way. Every PM biases toward what it owns — deno is +//! to the front the same way. Every PM biases toward what it owns; deno is //! one member of the rule, not a special case. use std::path::{Path, PathBuf}; @@ -26,7 +26,7 @@ pub(crate) fn select_task_entry<'a>( overrides: &ResolutionOverrides, found: &[&'a crate::types::Task], ) -> &'a crate::types::Task { - // A `[tasks.overrides]` pin wins a same-name conflict — but only when the + // A `[tasks.overrides]` pin wins a same-name conflict, but only when the // user hasn't forced a PM/runner on the CLI/env, which outrank the config // file. The pin lists sources most-native first; the first candidate under // one of them wins (real recipe before a same-named alias). @@ -72,7 +72,7 @@ pub(crate) fn select_task_entry<'a>( /// is bumped below them. So `RUNNER_PM=deno run check` resolves a `deno:check` / /// `package.json:check` conflict to the native deno task instead of running the package.json /// script *through* deno; `--pm bun` pulls `package.json` to the front the same way. Every PM -/// biases toward what it owns — deno is one member of the rule, not a special case. A PM that +/// biases toward what it owns; deno is one member of the rule, not a special case. A PM that /// owns no modeled task source (Bundler, Composer) re-orders nothing. Fires only when a PM is /// forced; with no `--pm` / `RUNNER_PM` the ranking is unchanged. /// - When `[task_runner].prefer = [r1, r2, ...]` is set, runners in the list win in listed order @@ -134,7 +134,7 @@ fn base_source_priority(overrides: &ResolutionOverrides, source: TaskSource) -> .iter() .position(|r| r.task_source() == Some(source)) { - // Listed runners always beat unlisted ones — the offset + // Listed runners always beat unlisted ones; the offset // guarantees `default_tier + prefer.len()` never collides. return u16::try_from(idx).unwrap_or(u16::MAX); } @@ -143,7 +143,7 @@ fn base_source_priority(overrides: &ResolutionOverrides, source: TaskSource) -> /// The package manager forced via `--pm` / `RUNNER_PM`, if any. Only this /// cross-ecosystem CLI/env override (`overrides.pm`) biases source -/// selection — `runner.toml` PM overrides live in `pm_by_ecosystem` and +/// selection; `runner.toml` PM overrides live in `pm_by_ecosystem` and /// never do. The caller reads its [`PackageManager::owned_task_sources`] to /// rank the forced PM's own source(s) first. fn forced_pm(overrides: &ResolutionOverrides) -> Option { @@ -155,8 +155,8 @@ fn forced_pm(overrides: &ResolutionOverrides) -> Option { /// [`usize::MAX`] so they lose the tiebreak. /// /// Generalizes the depth-aware selection that previously only fired for -/// Deno projects so that — for any pair of source candidates tied on -/// [`source_priority`] — the one whose config sits in the nearest +/// Deno projects so that, for any pair of source candidates tied on +/// [`source_priority`], the one whose config sits in the nearest /// ancestor of cwd wins. Today this matters most in Deno + Node /// workspace layouts (member `package.json` near cwd vs root /// `deno.json`), and in Cargo + Make/Just/Taskfile setups where the @@ -172,7 +172,7 @@ pub(crate) fn source_depth(ctx: &ProjectContext, source: TaskSource) -> usize { // (e.g. `.cargo/config.toml` whose parent is // `/.cargo`). `ancestors()` only walks upward, // so the position lookup never matches and depth - // would otherwise collapse to `usize::MAX` — making + // would otherwise collapse to `usize::MAX`, making // any root-level source (`bacon.toml`, `Makefile`, // `justfile`) win every tiebreak by default and // starving `display_order` of the chance to choose. @@ -232,6 +232,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } @@ -306,7 +307,7 @@ mod tests { fn forced_bun_biases_toward_its_own_package_json() { // Generalization past deno: bun owns `package.json`, so `--pm bun` // pulls it to the front. Here package.json already led deno, so the - // winner is unchanged — but now it wins *because* bun biases toward + // winner is unchanged, but now it wins *because* bun biases toward // it, not by default tier (see the turbo case for a winner flip). let ctx = context(vec![ task("check", TaskSource::PackageJson), @@ -428,7 +429,7 @@ mod tests { #[test] fn tasks_prefer_turbo_first_keeps_turbo() { - // The inverse order leaves turbo winning — confirms it's the listed + // The inverse order leaves turbo winning, confirms it's the listed // order driving the choice, not a fixed bias. let ctx = context(vec![ task("build", TaskSource::TurboJson), diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index 5d9422f1..d0faa55b 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -1,4 +1,4 @@ -//! `runner schema` — emit committed JSON Schemas (feature `schema`). +//! `runner schema`, emit committed JSON Schemas (feature `schema`). use std::fmt::Write as _; use std::io::Write as _; @@ -44,7 +44,7 @@ pub(crate) fn config_schema() -> Result { /// Constrain `[tasks].prefer` and `[tasks.overrides]` values to the closed /// label vocabulary the resolver actually accepts (task runners, package -/// managers, source names — see `resolver::policies::resolve_source_label`), +/// managers, source names, see `resolver::policies::resolve_source_label`), /// instead of leaving them as unconstrained strings. Derived from /// [`crate::types::task_source_labels`] so the schema can't drift from the /// resolver's own vocabulary. @@ -97,7 +97,7 @@ fn write_all_schemas(dir: &Path) -> Result<()> { /// instead of an unhandled panic reaching `runner schema --all`'s caller. /// `render_init_template` panics on `FIELD_TEMPLATE`/`RunnerConfig` drift /// by design (a hard, loud failure is exactly right for the drift-guard -/// test that normally catches this before merge) — this is only the +/// test that normally catches this before merge); this is only the /// production CLI path's translation of that same failure into a /// `Result`, with the default panic hook suppressed so users see one /// clean error instead of a raw backtrace followed by one. @@ -132,7 +132,7 @@ fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { /// How a [`FIELD_TEMPLATE`] entry's inline hint is produced. #[derive(Clone, Copy)] enum FieldHint { - /// Hand-written hint text — for booleans and fields whose accepted + /// Hand-written hint text, for booleans and fields whose accepted /// values aren't a small fixed set ([`broader_vocab`] validates /// their example value instead of enumerating every label inline). Static(&'static str), @@ -141,21 +141,21 @@ enum FieldHint { ClosedSet { suffix: Option<&'static str> }, /// The field's real accepted-value set, each with a short /// parenthetical note. Every label [`accepted_labels`] returns for - /// this field must have exactly one entry here — enforced by + /// this field must have exactly one entry here, enforced by /// `field_template_hints_cover_every_accepted_label`. Annotated(&'static [(&'static str, &'static str)]), } /// (section, field) -> (commented-out value, hint). Every field /// [`crate::config::RunnerConfig`]'s schemars metadata declares must -/// have an entry here, and every entry must name a real field — both +/// have an entry here, and every entry must name a real field, both /// enforced by [`render_init_template`]'s own assertions, which run -/// whenever `committed_init_template_matches_generator` exercises it — +/// whenever `committed_init_template_matches_generator` exercises it, /// so a new config field can't ship without scaffold coverage. Values /// are either the field's real built-in default (`fallback`, /// `on_mismatch`, the three booleans) or, where there's no single /// sensible default to show (an unset PM override, an empty preference -/// list), a hand-picked illustrative example — validated against the +/// list), a hand-picked illustrative example, validated against the /// real accepted vocabulary (`accepted_labels`/`broader_vocab`) by /// `field_template_values_use_real_accepted_labels`. const FIELD_TEMPLATE: &[(&str, &str, &str, FieldHint)] = &[ @@ -203,6 +203,15 @@ const FIELD_TEMPLATE: &[(&str, &str, &str, FieldHint)] = &[ suffix: Some("(absent = each PM's own default)"), }, ), + ( + "install", + "on_collision", + r#""resolve""#, + FieldHint::Annotated(&[ + ("resolve", "one writer per install dir, rest shadowed"), + ("error", "refuse to pick"), + ]), + ), ( "resolution", "fallback", @@ -286,12 +295,12 @@ fn render_hint(section: &str, field: &str, hint: &FieldHint) -> String { } /// The real, closed accepted-value set for a config field with a small -/// fixed vocabulary — derived from the same types/functions the resolver +/// fixed vocabulary, derived from the same types/functions the resolver /// uses to parse that field, so it cannot drift from what's actually /// accepted. `None` for booleans and fields with no single fixed set /// (see [`broader_vocab`] for those with a large-but-real vocabulary). fn accepted_labels(section: &str, field: &str) -> Option> { - use crate::resolver::{FallbackPolicy, MismatchPolicy, ScriptPolicy}; + use crate::resolver::{CollisionPolicy, FallbackPolicy, MismatchPolicy, ScriptPolicy}; use crate::types::{Ecosystem, PackageManager, TaskRunner}; match (section, field) { @@ -316,6 +325,9 @@ fn accepted_labels(section: &str, field: &str) -> Option> { .filter_map(|p| p.label()) .collect(), ), + ("install", "on_collision") => { + Some(CollisionPolicy::ALL.iter().map(|p| p.label()).collect()) + } ("resolution", "fallback") => Some(FallbackPolicy::ALL.iter().map(|p| p.label()).collect()), ("resolution", "on_mismatch") => { Some(MismatchPolicy::ALL.iter().map(|p| p.label()).collect()) @@ -344,10 +356,10 @@ fn broader_vocab(section: &str, field: &str) -> Option> { } /// Assert every string leaf in a [`FIELD_TEMPLATE`] `value` literal is a -/// real accepted value for `section.field` — [`accepted_labels`] when the +/// real accepted value for `section.field`, [`accepted_labels`] when the /// field has a closed vocabulary, else [`broader_vocab`], else no check /// (plain booleans have neither). `value` parses directly as a bare TOML -/// value expression (scalar, array, or inline table) — the same syntax +/// value expression (scalar, array, or inline table), the same syntax /// it's spliced into after `field = ` in the real scaffold. fn assert_value_uses_real_labels(section: &str, field: &str, value: &str) { let Some(vocab) = accepted_labels(section, field).or_else(|| broader_vocab(section, field)) @@ -381,7 +393,7 @@ fn collect_string_leaves(value: &toml::Value, out: &mut Vec) { const INIT_TEMPLATE_HEADER: &str = r"#:schema ./runner.toml.schema.json -# runner.toml — project task-runner configuration. +# runner.toml, project task-runner configuration. # Docs: https://runner.kjanat.dev # # Every key below is commented out, showing either its built-in default or an @@ -392,7 +404,7 @@ const INIT_TEMPLATE_HEADER: &str = r"#:schema ./runner.toml.schema.json /// Render the `runner.toml` scaffold `runner config init` writes. /// -/// Walks [`crate::config::RunnerConfig`]'s schemars metadata — section +/// Walks [`crate::config::RunnerConfig`]'s schemars metadata. Section /// order and doc-comment descriptions come straight from the struct, so /// a field can't be silently forgotten or its prose silently drift from /// the type. [`FIELD_TEMPLATE`] supplies the one thing schemars can't: @@ -401,8 +413,8 @@ const INIT_TEMPLATE_HEADER: &str = r"#:schema ./runner.toml.schema.json /// # Panics /// /// Panics if `RunnerConfig`'s schema is malformed (a property without a -/// `$defs` `$ref`) or a schema field has no [`FIELD_TEMPLATE`] entry — -/// both indicate a real bug the generator should surface loudly, not +/// `$defs` `$ref`) or a schema field has no [`FIELD_TEMPLATE`] entry. +/// Both indicate a real bug the generator should surface loudly, not /// paper over, since this only ever runs under `just gen-schema`. pub(crate) fn render_init_template() -> String { let schema = serde_json::to_value(schemars::schema_for!(crate::config::RunnerConfig)) @@ -430,7 +442,7 @@ pub(crate) fn render_init_template() -> String { // Deprecated sections (e.g. `task_runner`, superseded by `tasks`) // still need their FIELD_TEMPLATE entries validated so drift is // caught, but new users shouldn't be handed a deprecated section - // in their starter file — so skip printing it entirely. + // in their starter file, so skip printing it entirely. for field in properties.keys() { let &(entry_section, entry_field, value, hint) = FIELD_TEMPLATE .iter() @@ -476,7 +488,7 @@ pub(crate) fn render_init_template() -> String { .collect(); assert!( orphaned.is_empty(), - "FIELD_TEMPLATE has entries for fields RunnerConfig no longer declares: {orphaned:?} — \ + "FIELD_TEMPLATE has entries for fields RunnerConfig no longer declares: {orphaned:?}, \ remove them" ); @@ -484,9 +496,9 @@ pub(crate) fn render_init_template() -> String { } /// Rewrite a rustdoc intra-doc link (`` [`Type::field`] ``) into plain -/// backticked text (`` `Type::field` ``) — the square brackets signal a -/// hyperlink to rustdoc/schemars consumers, but read as stray punctuation -/// in a plain-text scaffold comment. +/// backticked text (`` `Type::field` ``). The square brackets signal a +/// hyperlink to rustdoc, but read as stray punctuation in the plain-text +/// scaffold comments this feeds. fn strip_intra_doc_links(line: &str) -> String { let mut out = String::with_capacity(line.len()); let mut rest = line; @@ -594,11 +606,11 @@ fn patch_source_schema(schema: &mut Value, command: &str) { } /// `Overrides.prefer_sources`/`task_source_pins` hold the same structured -/// source labels as `SourceEntry.kind`, command-dependent like it — reuse +/// source labels as `SourceEntry.kind`, command-dependent like it; reuse /// `TaskSourceLabel` instead of leaving them generic strings. (Every other /// `Overrides` label field is backed by a real enum and gets its schema -/// constraint straight from `#[derive(schemars::JsonSchema)]` on that enum -/// — no hand-built schema needed there.) +/// constraint straight from `#[derive(schemars::JsonSchema)]` on that enum, +/// no hand-built schema needed there.) fn patch_overrides_source_labels(defs: &mut Map) { if !defs.contains_key("Overrides") { return; @@ -654,7 +666,7 @@ fn patch_def_field( } } -/// Like [`patch_def_field`], but for an array-typed field — constrains its +/// Like [`patch_def_field`], but for an array-typed field, constrains its /// `items` schema instead of the field itself. fn patch_def_array_items( defs: &mut Map, @@ -669,7 +681,7 @@ fn patch_def_array_items( } } -/// Like [`patch_def_array_items`], but for a map-of-array field — constrains +/// Like [`patch_def_array_items`], but for a map-of-array field, constrains /// the array items nested under `additionalProperties`. fn patch_def_map_array_items( defs: &mut Map, @@ -689,7 +701,7 @@ fn task_source_label_schema(command: &str) -> Value { json!({ "type": "string", "enum": source_labels(command) }) } -/// Closed set for the `why` `provider` field — the tool family that +/// Closed set for the `why` `provider` field, the tool family that /// executes the task. Derived from [`crate::types::TaskSource::all`] /// through [`super::why::provider_label`], the same function `why` calls /// at runtime, so the committed schema's enum can't drift from it. @@ -702,10 +714,10 @@ fn provider_labels() -> Vec<&'static str> { /// Closed label set for `command`'s source labels, derived from /// [`crate::types::TaskSource::all`] through the same label functions -/// `list`/`doctor`/`why` use at runtime — so the committed schema's enum +/// `list`/`doctor`/`why` use at runtime, so the committed schema's enum /// can't drift from what the binary actually emits. `list` uses the flat /// label convention; `doctor`/`why` use the structured one (only -/// `CargoAliases` differs — `"cargo-alias"` vs `"cargo"`). +/// `CargoAliases` differs, `"cargo-alias"` vs `"cargo"`). fn source_labels(command: &str) -> Vec<&'static str> { use crate::schema::labels::{flat_source_label, structured_source_label}; use crate::types::TaskSource; @@ -750,6 +762,45 @@ fn description(command: &str) -> String { mod tests { use serde_json::Value; + #[test] + fn doctor_conflict_schema_distinguishes_task_and_install_metadata() { + let schema = super::output_schema::>("doctor") + .expect("doctor schema should generate"); + let variants = schema["$defs"]["Conflict"]["oneOf"] + .as_array() + .expect("Conflict should be split by kind"); + let variant = |kind: &str| { + variants + .iter() + .find(|variant| variant["properties"]["kind"]["const"] == kind) + .unwrap_or_else(|| panic!("missing {kind} conflict schema")) + }; + + let task = variant("duplicate-task-name"); + assert_eq!( + task["properties"]["selected"]["description"], + "FQN of the winning task." + ); + assert_eq!( + task["properties"]["shadowed"]["description"], + "FQNs of the shadowed tasks." + ); + + let install = variant("install-dir-collision"); + assert_eq!( + install["properties"]["selected"]["description"], + "Label of the selected package manager." + ); + assert_eq!( + install["properties"]["selector"]["description"], + "Path of the conflicting installation directory." + ); + assert_eq!( + install["properties"]["shadowed"]["description"], + "Labels of the shadowed package managers." + ); + } + #[test] fn committed_doctor_example_includes_quiet_override() { let raw = std::fs::read_to_string("schemas/doctor.example.json") @@ -789,7 +840,7 @@ mod tests { // source_labels(command) used to be three hand-maintained arrays, // free to drift from the label functions `list`/`why`/`doctor` // actually call at runtime. Now that it's derived, this test is a - // tautology against today's implementation — its job is to catch a + // tautology against today's implementation; its job is to catch a // future regression back to a hardcoded list. for command in ["list", "doctor", "why"] { let schema = super::task_source_label_schema(command); @@ -813,7 +864,7 @@ mod tests { // correct; it says nothing about whether `just gen-schema` was // actually re-run before committing. Read every checked-in schema // that defines TaskSourceLabel directly off disk so a stale - // artifact — the generator fixed, the commit forgotten — fails + // artifact, the generator fixed, the commit forgotten, fails // here instead of shipping silently. for &(path, command) in COMMITTED_SCHEMAS_WITH_TASK_SOURCE_LABEL { let raw = std::fs::read_to_string(path) @@ -830,7 +881,7 @@ mod tests { enum_values, runtime_labels(command), "{path}: committed TaskSourceLabel enum has drifted from the runtime label \ - function — run `just gen-schema` and commit the result" + function, run `just gen-schema` and commit the result" ); } } @@ -840,7 +891,7 @@ mod tests { // provider_labels() used to be a hand-maintained PROVIDER_LABELS // array, free to drift from cmd::why::provider_label. Now that // it's derived, this test is a tautology against today's - // implementation — its job is to catch a future regression back + // implementation; its job is to catch a future regression back // to a hardcoded list. let enum_values = super::provider_labels(); let runtime_values: Vec<&str> = crate::types::TaskSource::all() @@ -871,7 +922,7 @@ mod tests { enum_values, super::provider_labels(), "schemas/why.schema.json: committed ProviderLabel enum has drifted from \ - cmd::why::provider_label — run `just gen-schema` and commit the result" + cmd::why::provider_label, run `just gen-schema` and commit the result" ); } @@ -882,7 +933,7 @@ mod tests { .expect("committed init template should be readable"); assert_eq!( generated, committed, - "schemas/runner.init.toml has drifted from render_init_template() — run `just \ + "schemas/runner.init.toml has drifted from render_init_template(), run `just \ gen-schema` and commit the result" ); } diff --git a/src/cmd/why.rs b/src/cmd/why.rs index 1a5855ca..cf7b810c 100644 --- a/src/cmd/why.rs +++ b/src/cmd/why.rs @@ -1,4 +1,4 @@ -//! `runner why ` — explain how a specific task name would be +//! `runner why `, explain how a specific task name would be //! dispatched. //! //! Walks the same source-selection chain used by `runner run`, plus the PM @@ -31,7 +31,7 @@ pub(crate) fn why( task: &str, json: bool, ) -> Result<()> { - // Interpret the token exactly like `run` does — qualified syntax + // Interpret the token exactly like `run` does: qualified syntax // (`deno:lint`), FQN (`root:package.json#name`), and the exact-name // fallback for colon-named scripts all resolve here, so `why` can // explain the very dispatch `run` would perform for the same token. @@ -469,7 +469,7 @@ pub(super) const fn provider_label(source: TaskSource) -> &'static str { } /// Effective command preview for the candidate. `why` only resolves the -/// PM for the selected task — other candidates report null. Delegates the +/// PM for the selected task; other candidates report null. Delegates the /// per-source dispatch to [`labels::resolved_command`], shared with /// `doctor`. fn resolved_command(task: &Task, pm_decision: Option<&PmDecision>) -> Option { @@ -513,7 +513,7 @@ fn print_human( for c in candidates { let depth = source_depth(ctx, c.source); let depth_label = if depth == usize::MAX { - "—".to_string() + "-".to_string() } else { depth.to_string() }; @@ -586,6 +586,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } @@ -684,7 +685,7 @@ mod tests { ); let json = serde_json::to_value(&report).expect("report should serialize"); - assert_eq!(json["schema_version"], 1); + assert_eq!(json["schema_version"], 2); assert_eq!(json["kind"], "runner.why"); assert_eq!(json["query"], "t"); assert_eq!(json["pm_resolution"], serde_json::Value::Null); @@ -735,7 +736,7 @@ mod tests { fn report_describes_qualifier_mismatch_as_filtered_not_runner_restricted() { // `why deno:build` when "build" exists elsewhere but not under // deno.json: `candidates` still lists the same-named tasks - // (useful diagnostic — `lookup_token` surfaces them precisely so + // (useful diagnostic, `lookup_token` surfaces them precisely so // this case is explainable), but nothing is eligible under the // `deno:` qualifier, so `selected` is None. The "filtered" reason // must name the qualifier, not blame a --runner restriction that @@ -787,7 +788,7 @@ mod tests { assert_eq!(json["decision"]["strategy"], "ranked"); assert_eq!(json["candidates"].as_array().map(Vec::len), Some(2)); // package.json resolved depends on PM resolution, which only the - // selected task gets — and no PM decision was passed here. + // selected task gets, and no PM decision was passed here. assert_eq!( json["candidates"][0]["task"]["resolved"], serde_json::Value::Null diff --git a/src/complete/grouped.zsh b/src/complete/grouped.zsh index 6311ae68..e81ab2c8 100644 --- a/src/complete/grouped.zsh +++ b/src/complete/grouped.zsh @@ -3,10 +3,10 @@ # Dynamic completion for {BIN} with tag-based grouping. # # Placeholders replaced at registration time by the Rust binary: -# {NAME} — sanitised binary name (function identifier) -# {BIN} — binary name as seen by the shell -# {COMPLETER} — path to the binary that produces completions -# {VAR} — env-var that activates CompleteEnv (default: COMPLETE) +# {NAME} , sanitised binary name (function identifier) +# {BIN} , binary name as seen by the shell +# {COMPLETER} , path to the binary that produces completions +# {VAR} , env-var that activates CompleteEnv (default: COMPLETE) # # The binary outputs one candidate per line: # TAG \x1f VALUE [\t DESCRIPTION] @@ -25,13 +25,13 @@ function _clap_dynamic_completer_{NAME}() { # and our tracing doesn't bleed into their prompt. `-o NULL_GLOB` # silences "no matches found" errors that `_files` internals and # user zstyles (e.g. specs tagged `globbed-files`) would otherwise - # raise into the prompt when an unquoted `*` fails to match — and, + # raise into the prompt when an unquoted `*` fails to match, and, # unlike `NO_NOMATCH`, it does so without leaving the literal glob # (e.g. `*(/)` from `_files -/` in a dir with no subdirectories) # as a candidate that then gets inserted into the command line. # `-o EXTENDED_GLOB` is required because zsh's own `_files` builds # qualifier patterns like `*(#q-/)` and uses `(#b)` backreferences - # internally — without extended glob, those raise + # internally, without extended glob, those raise # `bad pattern: *(#q-/):globbed-files` the moment `_files -/` runs. emulate -L zsh -o NULL_GLOB -o EXTENDED_GLOB @@ -58,7 +58,7 @@ function _clap_dynamic_completer_{NAME}() { # `noglob` as a PRECOMMAND modifier (not `setopt noglob`) # keeps globs like `*(*)` from expanding at the call site so # they reach `_files` literally, while leaving globbing ON - # inside `_files` itself — `_path_files` internally does + # inside `_files` itself, `_path_files` internally does # `tmp1=( $~tmp1 )` to list directories, and a function-wide # `setopt noglob` would leave that pattern (e.g. `*(-/)` from # `_files -/` in a dir with no subdirectories) unexpanded and @@ -85,7 +85,7 @@ function _clap_dynamic_completer_{NAME}() { # Empty assignment matters: `local NAME` with no value, on an # already-set local (Pass 1 localized __runner_g), is the typeset # display form and prints `__runner_g=` to the terminal. - # Normally invisible — zle redisplay redraws the line — but with + # Normally invisible, zle redisplay redraws the line, but with # oh-my-zsh's `COMPLETION_WAITING_DOTS` indicator already on the # line, the spurious print survives the redraw and lingers next to # the dots after the menu opens. diff --git a/src/complete/mod.rs b/src/complete/mod.rs index 82aac2ad..856fb49f 100644 --- a/src/complete/mod.rs +++ b/src/complete/mod.rs @@ -24,7 +24,7 @@ pub(crate) const SHELLS: Shells<'static> = /// /// Emits `TAG\x1fVALUE\tDESCRIPTION` lines from [`write_complete`] and /// generates a registration script that groups completions under separate -/// `compadd -V` calls per tag — producing `-- tag --` section headers. +/// `compadd -V` calls per tag, producing `-- tag --` section headers. struct GroupedZsh; impl EnvCompleter for GroupedZsh { @@ -80,7 +80,7 @@ impl EnvCompleter for GroupedZsh { // Short-circuit when the current position is a path-typed flag value: // emit a sentinel so the zsh script can delegate to its native // `_files` builtin (which understands `~`, named dirs, `cdpath`, - // globs — all things clap's Rust-side path lister doesn't know). + // globs, all things clap's Rust-side path lister doesn't know). if let Some(flags) = detect_path_files_flags(cmd, &args, index) { write!(buf, "{PATHFILES_SENTINEL}\t{flags}")?; return Ok(()); @@ -139,7 +139,7 @@ fn detect_path_files_flags( let current = args.get(index)?.to_string_lossy(); let chain = active_command_chain(cmd, args, index); - // `--flag=value` — the current token carries both, we're completing `value`. + // `--flag=value`, the current token carries both; we're completing `value`. if let Some((flag, _value)) = current.split_once('=') && let Some(long) = flag.strip_prefix("--") && let Some(hint) = find_long_value_hint(&chain, long) @@ -147,7 +147,7 @@ fn detect_path_files_flags( return zsh_files_flags(hint); } - // `-oVALUE` — short flag with its value attached in the same token. + // `-oVALUE`, short flag with its value attached in the same token. // Only meaningful if the first char after `-` is a value-taking short. if let Some(rest) = current.strip_prefix('-') && !current.starts_with("--") @@ -181,7 +181,7 @@ fn detect_path_files_flags( /// Walk `args[1..index]` and descend into matching subcommands to build the /// active command chain (root first, deepest last). Stops as soon as a -/// positional argument fails to match any subcommand of the current node — +/// positional argument fails to match any subcommand of the current node; /// that's where positionals for the leaf command begin. Leading options /// and their values are skipped. fn active_command_chain<'a>( @@ -217,7 +217,7 @@ fn active_command_chain<'a>( // Short option. Handle two forms: // `-o value` (two tokens) → skip 2 if `-o` takes a value. // `-oPATH` / `-abc` → value attached (if any) or - // boolean cluster — skip 1. + // boolean cluster, skip 1. if token.len() == 2 && let Some(c) = token.chars().nth(1) && short_flag_takes_value(&chain, c) @@ -233,7 +233,7 @@ fn active_command_chain<'a>( current = sub; i += 1; } else { - // First positional that isn't a subcommand — we've hit the + // First positional that isn't a subcommand, we've hit the // leaf command's own positionals (task name, etc). break; } @@ -246,7 +246,7 @@ fn active_command_chain<'a>( /// [`find_long_value_hint`]: the subcommand-local definition wins over an /// ancestor's. This matters when a subcommand reuses a root flag name /// with a different [`clap::ArgAction`] (e.g. root defines `--flag ` -/// and a subcommand redeclares `--flag` as a boolean) — the walker must +/// and a subcommand redeclares `--flag` as a boolean); the walker must /// honour the leaf command's semantics. fn long_flag_takes_value(chain: &[&clap::Command], name: &str) -> bool { chain @@ -316,8 +316,8 @@ fn find_short_value_hint(chain: &[&clap::Command], c: char) -> Option /// Returns `None` for hints that aren't path-like (so regular clap /// completion keeps running). /// -/// `ExecutablePath` uses zsh's `(*)` glob qualifier — which matches files -/// the current user has execute permission on — so completion doesn't +/// `ExecutablePath` uses zsh's `(*)` glob qualifier, which matches files +/// the current user has execute permission on, so completion doesn't /// suggest non-executable regular files for args that only accept /// binaries. Written without surrounding quotes because the caller /// (`grouped.zsh`) disables globbing locally before splitting the string @@ -528,7 +528,7 @@ mod tests { } /// Boolean short flag (no value) must NOT cause the walker to skip - /// the next token — otherwise `runner clean -y build` would wrongly + /// the next token, otherwise `runner clean -y build` would wrongly /// consume `build` as `-y`'s value and never descend into it. #[test] fn detect_path_files_ignores_boolean_short_flag() { diff --git a/src/config.rs b/src/config.rs index 305008b1..d8e2fc05 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -//! `runner.toml` — project-level configuration. +//! `runner.toml`, project-level configuration. //! //! The file lives at the project root. The resolver reads it as step 4 of //! the precedence chain (after CLI flags and environment variables, before @@ -51,8 +51,8 @@ pub(crate) const CONFIG_DIRS: [&str; 2] = ["", ".config"]; /// Starter `runner.toml` scaffolded by `runner config init`. Generated from /// [`RunnerConfig`]'s schemars metadata (section/field doc comments) plus a -/// small hand-picked value/hint table — see -/// `cmd::schema::render_init_template` — so a field can't silently ship +/// small hand-picked value/hint table, see +/// `cmd::schema::render_init_template`, so a field can't silently ship /// without scaffold coverage. Regenerate with `just gen-schema` after /// changing a section struct; a drift-guard test enforces this file stays /// in sync. @@ -80,34 +80,39 @@ pub(crate) struct LoadedConfig { schemars(deny_unknown_fields) )] pub(crate) struct RunnerConfig { - /// `[pm]` — per-ecosystem package-manager overrides. + /// `[pm]`, per-ecosystem package-manager overrides. #[serde(default)] pub pm: PmSection, - /// `[tasks]` — persistent task-source preference (global order + per-task pins). + /// `[tasks]`, persistent task-source preference (global order + per-task pins). #[serde(default)] pub tasks: TasksSection, - /// `[task_runner]` — task-runner preferences. Deprecated; superseded + /// `[task_runner]`, task-runner preferences. Deprecated; superseded /// by [`Self::tasks`]. + #[cfg_attr( + feature = "schema", + schemars(description = "`[task_runner]`, task-runner preferences. Deprecated; \ + superseded by `[tasks]`.") + )] #[serde(default, rename = "task_runner")] pub task_runner: TaskRunnerSection, - /// `[install]` — restrict which detected PMs `runner install` runs. + /// `[install]`, restrict which detected PMs `runner install` runs. #[serde(default)] pub install: InstallSection, - /// `[resolution]` — resolver-policy knobs. + /// `[resolution]`, resolver-policy knobs. #[serde(default)] pub resolution: ResolutionSection, - /// `[chain]` — failure policy for multi-task chains. + /// `[chain]`, failure policy for multi-task chains. #[serde(default)] pub chain: ChainSection, - /// `[github]` — GitHub Actions integration (output grouping). + /// `[github]`, GitHub Actions integration (output grouping). #[serde(default)] pub github: GitHubSection, - /// `[parallel]` — presentation of parallel (`-p`) chain output. + /// `[parallel]`, presentation of parallel (`-p`) chain output. #[serde(default)] pub parallel: ParallelSection, } -/// `[install]` section — restrict which detected package managers +/// `[install]` section, restrict which detected package managers /// `runner install` runs with. Absent or empty installs every detected /// PM (the default). Overridden by `RUNNER_INSTALL_PMS`. /// @@ -146,9 +151,23 @@ pub(crate) struct InstallSection { schemars(extend("enum" = ["deny", "allow", null])) )] pub scripts: Option, + + /// What to do when two or more package managers in the install set write + /// the same directory (a node PM plus a `nodeModulesDir`-enabled Deno both + /// materializing `node_modules/`). `"resolve"` (the default) installs with + /// one writer per directory and shadows the rest, the way a duplicate task + /// name resolves to one source; listing several writers in `pms` is consent + /// and runs them all, serialized over the shared tree. `"error"` refuses to + /// pick and fails instead. Overridden by `RUNNER_INSTALL_ON_COLLISION`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "schema", + schemars(extend("enum" = ["resolve", "error", null])) + )] + pub on_collision: Option, } -/// `[chain]` section — failure policy for `run -s/-p` chains and +/// `[chain]` section, failure policy for `run -s/-p` chains and /// `runner install `. // Fields are `Option` rather than `bool` so the resolver can // distinguish "user explicitly set false" from "user didn't say": @@ -178,7 +197,7 @@ pub(crate) struct ChainSection { pub keep_going: Option, /// Parallel only: terminate sibling tasks immediately on first - /// failure (forcible kill, not graceful shutdown — uncatchable on + /// failure (forcible kill, not graceful shutdown, uncatchable on /// Unix). Mutually exclusive with `keep_going`. Equivalent to /// `--kill-on-fail` / `RUNNER_KILL_ON_FAIL`. Ignored in sequential /// contexts. @@ -186,7 +205,7 @@ pub(crate) struct ChainSection { pub kill_on_fail: Option, } -/// `[github]` section — GitHub Actions integration. Both knobs only take +/// `[github]` section, GitHub Actions integration. Both knobs only take /// effect under GitHub Actions (gated at the call site by /// `actions_rs::env::is_github_actions`); in a normal terminal nothing here /// changes behavior. @@ -209,6 +228,15 @@ pub(crate) struct GitHubSection { /// [`Self::group_output`] is also true. The non-CI equivalent is /// `[parallel].grouped` (default `false`), so CI and local diverge unless /// you set them to match. + #[cfg_attr( + feature = "schema", + schemars( + description = "Under GitHub Actions, group parallel (`-p`) output: buffer each task \ + and print it as one block on completion instead of interleaving lines \ + live. Defaults to `true`, but only when `group_output` is also true. \ + The non-CI equivalent is `[parallel].grouped` (default `false`)." + ) + )] #[serde(default = "default_github_group_parallel")] pub group_parallel: bool, } @@ -234,7 +262,7 @@ const fn default_github_group_parallel() -> bool { true } -/// `[parallel]` section — how parallel (`-p`) chains present their output +/// `[parallel]` section, how parallel (`-p`) chains present their output /// **outside** GitHub Actions. (Under GitHub Actions, see /// `[github].group_parallel` instead.) #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -245,7 +273,7 @@ const fn default_github_group_parallel() -> bool { )] pub(crate) struct ParallelSection { /// Buffer each parallel task's output and print it as one contiguous - /// block the moment that task finishes (completion order — first done, + /// block the moment that task finishes (completion order, first done, /// first shown), instead of interleaving prefixed lines live. Defaults to /// `false` (the live `[task]`-prefixed muxer); set `true` to group even in /// a plain terminal, where a colored header delimits each block. @@ -253,7 +281,7 @@ pub(crate) struct ParallelSection { pub grouped: bool, } -/// `[pm]` section — per-ecosystem package manager overrides. +/// `[pm]` section, per-ecosystem package manager overrides. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[cfg_attr( feature = "schema", @@ -279,11 +307,11 @@ pub(crate) struct PmSection { pub python: Option, } -/// `[task_runner]` section — **deprecated**. Use `[tasks]` instead. +/// `[task_runner]` section, **deprecated**. Use `[tasks]` instead. /// /// Kept for backward compatibility: existing `[task_runner].prefer` files /// keep working (and emit a deprecation warning), but `[tasks].prefer` is the -/// supported successor — rank-only and able to name package managers, not just +/// supported successor, rank-only and able to name package managers, not just /// task runners. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[cfg_attr( @@ -292,7 +320,7 @@ pub(crate) struct PmSection { schemars(deny_unknown_fields, extend("deprecated" = true)) )] pub(crate) struct TaskRunnerSection { - /// **Deprecated — use `[tasks].prefer` instead** (rank-only, and accepts + /// **Deprecated, use `[tasks].prefer` instead** (rank-only, and accepts /// package managers like `bun`, not just task runners). Migration: /// `[task_runner].prefer = ["turbo"]` → `[tasks].prefer = ["turbo"]`. /// @@ -305,7 +333,7 @@ pub(crate) struct TaskRunnerSection { pub prefer: Vec, } -/// `[tasks]` section — persistent task-source preference for ambiguous task +/// `[tasks]` section, persistent task-source preference for ambiguous task /// names (a name that exists under more than one source, e.g. a `package.json` /// script *and* a `turbo` task). /// @@ -313,7 +341,7 @@ pub(crate) struct TaskRunnerSection { /// (`turbo`, `make`, …), a package manager (`bun`, `npm`, `pnpm`, `yarn`, /// `deno`, …), or a source name (`package.json`, `deno`, …). Package-manager /// labels map to the script source they run (`bun` → `package.json`). -/// Selection here is **rank-only**: it never hard-rejects an unlisted source, +/// Selection here is **rank-only**: it never hard-rejects an unlisted source; /// it only reorders. An explicit CLI qualifier (`package.json:test`), /// `--runner`, or `--pm`/`RUNNER_PM` still outranks these file settings. #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -332,11 +360,19 @@ pub(crate) struct TasksSection { /// Per-task pins that override [`Self::prefer`] for specific names: /// `overrides = { dev = "bun", build = "turbo" }`. A pin to a source the /// task doesn't have falls through to the normal ranking (no hard error). + #[cfg_attr( + feature = "schema", + schemars( + description = "Per-task pins that override `prefer` for specific names: `overrides = \ + { dev = \"bun\", build = \"turbo\" }`. A pin to a source the task \ + doesn't have falls through to the normal ranking (no hard error)." + ) + )] #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub overrides: BTreeMap, } -/// `[resolution]` section — resolver policy knobs. +/// `[resolution]` section, resolver policy knobs. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[cfg_attr( feature = "schema", @@ -344,15 +380,15 @@ pub(crate) struct TasksSection { schemars(deny_unknown_fields) )] pub(crate) struct ResolutionSection { - /// `probe` (default) — PATH probe in canonical order when no signals - /// match; `npm` — legacy silent fallback; `error` — refuse to proceed. + /// `probe` (default), PATH probe in canonical order when no signals + /// match; `npm`, legacy silent fallback; `error`, refuse to proceed. #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr( feature = "schema", schemars(extend("enum" = ["probe", "npm", "error", null])) )] pub fallback: Option, - /// `warn` (default), `error`, `ignore` — how to react when declaration + /// `warn` (default), `error`, `ignore`, how to react when declaration /// (manifest field) disagrees with detection (lockfile). #[serde(skip_serializing_if = "Option::is_none")] #[cfg_attr( @@ -366,13 +402,13 @@ pub(crate) struct ResolutionSection { /// [`INIT_TEMPLATE`]. A key absent from this table is reported as an /// [`DetectionWarning::UnknownConfigKey`] rather than aborting the load, so a /// config written by a newer `runner` never bricks an older binary (and vice -/// versa). Keep in sync when adding a section or field — the +/// versa). Keep in sync when adding a section or field; the /// `known_schema_covers_every_section` test guards section-level drift. const KNOWN_SCHEMA: &[(&str, &[&str])] = &[ ("pm", &["node", "python"]), ("task_runner", &["prefer"]), ("tasks", &["prefer", "overrides"]), - ("install", &["pms", "scripts"]), + ("install", &["pms", "scripts", "on_collision"]), ("resolution", &["fallback", "on_mismatch"]), ("chain", &["keep_going", "kill_on_fail"]), ("github", &["group_output", "group_parallel"]), @@ -414,8 +450,8 @@ pub(crate) fn collect_unknown_keys(value: &toml::Value) -> Vec /// Returns `Ok(None)` when no candidate exists; `Ok(Some(_))` otherwise, with /// `LoadedConfig::path` set to the file actually loaded. The parse is /// forward-compatible: unknown sections/fields are tolerated (and returned as -/// `warnings`) so version skew never aborts the load. Genuine failures — -/// unreadable file, malformed TOML, or a wrong-typed *known* field — still +/// `warnings`) so version skew never aborts the load. Genuine failures, +/// unreadable file, malformed TOML, or a wrong-typed *known* field, still /// propagate as errors. /// /// # Errors @@ -720,7 +756,7 @@ mod tests { #[test] fn tasks_section_validates() { - // `[tasks]` with a PM label and a per-task pin is a valid config — + // `[tasks]` with a PM label and a per-task pin is a valid config, // the same check `runner config validate` runs. let dir = TempDir::new("config-tasks-valid"); fs::write( @@ -756,7 +792,7 @@ mod tests { #[test] fn load_warns_on_unknown_section_without_failing() { // Forward compat: a section this build doesn't know (a typo, or one a - // newer runner added) must not abort the load — it warns and the rest + // newer runner added) must not abort the load; it warns and the rest // of the config still applies. let dir = TempDir::new("config-unknown-key"); fs::write( @@ -834,7 +870,7 @@ mod tests { continue; } // Field lines are `key = ...`, shipped commented-out. Strip one - // leading `#`, then keep only a bare-identifier left of `=` — that + // leading `#`, then keep only a bare-identifier left of `=`; that // shape excludes the prose comments, which carry no `key =`. let body = trimmed.strip_prefix('#').map_or(trimmed, str::trim); let Some((lhs, _)) = body.split_once('=') else { @@ -868,7 +904,7 @@ mod tests { assert_eq!( template, known, "INIT_TEMPLATE sections/fields must match KNOWN_SCHEMA (minus DEPRECATED_SECTIONS) \ - exactly — keep the section structs, the scaffold template, and KNOWN_SCHEMA in sync \ + exactly, keep the section structs, the scaffold template, and KNOWN_SCHEMA in sync \ when adding a knob" ); } @@ -877,7 +913,7 @@ mod tests { #[test] fn known_schema_matches_generated_runner_config_schema() { // known_schema_matches_init_template_sections_and_fields only - // catches INIT_TEMPLATE drifting from KNOWN_SCHEMA — a struct field + // catches INIT_TEMPLATE drifting from KNOWN_SCHEMA, a struct field // added to a section without updating either the scaffold or // KNOWN_SCHEMA passes that guard invisibly (template and KNOWN_SCHEMA // still agree with each other, just not with the real type; the @@ -933,7 +969,7 @@ mod tests { assert_eq!( generated, known, - "KNOWN_SCHEMA must match RunnerConfig's real (schemars-derived) shape exactly — a \ + "KNOWN_SCHEMA must match RunnerConfig's real (schemars-derived) shape exactly, a \ struct field with no KNOWN_SCHEMA entry is silently treated as unknown by \ collect_unknown_keys even though the typed deserializer accepts it" ); diff --git a/src/detect.rs b/src/detect.rs index 697bbbcc..66194633 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -8,7 +8,8 @@ use serde::Deserialize; use crate::tool; use crate::types::{ - DetectionWarning, NodeVersion, PackageManager, ProjectContext, Task, TaskRunner, TaskSource, + DetectionWarning, InstallDir, NodeVersion, PackageManager, ProjectContext, Task, TaskRunner, + TaskSource, }; /// Scan `dir` for known config/lock files and return a populated [`ProjectContext`]. @@ -28,11 +29,12 @@ pub(crate) fn detect(dir: &Path) -> ProjectContext { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), }; detect_package_managers(dir, &mut ctx); - detect_install_collisions(dir, &mut ctx); + detect_install_dirs(dir, &mut ctx); detect_task_runners(dir, &mut ctx); detect_node_version(dir, &mut ctx); detect_monorepo(dir, &mut ctx); @@ -48,14 +50,16 @@ pub(crate) fn detect(dir: &Path) -> ProjectContext { ctx } -// Install-directory collisions +// Install directories -/// Flag detected package managers that would install into the same -/// directory, so `runner install`/`doctor` can warn before fanning out -/// redundant installs over a shared tree. Today the one real case is -/// `node_modules`: any node-ecosystem PM writes it, and Deno joins only -/// when its `nodeModulesDir` materializes a local tree. -fn detect_install_collisions(dir: &Path, ctx: &mut ProjectContext) { +/// Record which detected package managers write which install directory. +/// Today the one shared directory is `node_modules`: any node-ecosystem PM +/// writes it, and Deno joins whenever it materializes a local tree rather than +/// resolving npm packages from its global cache (see +/// [`tool::deno::writes_node_modules`]). Whether a shared directory is a +/// *collision* is an install-time question ([`crate::cmd::install`] answers it +/// against the effective install set), so nothing is judged or warned here. +fn detect_install_dirs(dir: &Path, ctx: &mut ProjectContext) { let mut node_modules_writers: Vec = ctx .package_managers .iter() @@ -74,10 +78,10 @@ fn detect_install_collisions(dir: &Path, ctx: &mut ProjectContext) { { node_modules_writers.push(PackageManager::Deno); } - if node_modules_writers.len() >= 2 { - ctx.warnings.push(DetectionWarning::InstallDirCollision { + if !node_modules_writers.is_empty() { + ctx.install_dirs.push(InstallDir { dir: "node_modules", - pms: node_modules_writers, + writers: node_modules_writers, }); } } @@ -86,7 +90,7 @@ fn detect_install_collisions(dir: &Path, ctx: &mut ProjectContext) { /// Filesystem detector for a Node-ecosystem PM, keyed by the same /// [`crate::resolver::NODE_PROBE_ORDER`] the resolver's PATH probe and the -/// doctor's signals section use — one priority list instead of a third +/// doctor's signals section use, one priority list instead of a third /// copy encoded as if-else order. Only ever called with a PM drawn from /// that array, so the wildcard is unreachable in practice, not a /// silently-accepted gap. @@ -100,13 +104,61 @@ fn node_pm_detector(pm: PackageManager) -> fn(&Path) -> bool { } } -/// Detect a Node-ecosystem PM in `dir` alone (no upward walk), in -/// [`crate::resolver::NODE_PROBE_ORDER`] priority. +/// Detect a Node-ecosystem PM in `dir` alone (no upward walk). +/// +/// One lockfile answers by itself. Several is a question about intent, and the +/// committed lockfile answers it: a project that ships `bun.lock` and ignores +/// `package-lock.json` has said which manager is its own. Tracked status is the +/// signal, not ignore status, which is ambiguous in both directions (a +/// gitignored `bun.lock` can mean "we never commit lockfiles", which is +/// evidence the project *does* use bun). +/// +/// [`crate::resolver::NODE_PROBE_ORDER`] decides only when git can't: no +/// repository, no git, nothing committed, or several lockfiles committed. fn detect_local_node_pm(dir: &Path) -> Option { - crate::resolver::NODE_PROBE_ORDER + let present: Vec = crate::resolver::NODE_PROBE_ORDER + .iter() + .copied() + .filter(|&pm| node_pm_detector(pm)(dir)) + .collect(); + let (preferred, rest) = present.split_first()?; + if rest.is_empty() { + return Some(*preferred); + } + Some(committed_lockfile_pm(dir, &present).unwrap_or(*preferred)) +} + +/// The one package manager among `candidates` whose lockfile git tracks. +/// +/// `None` when git leaves the question open: it couldn't answer, nothing is +/// committed, or more than one lockfile is. A repository that commits two +/// lockfiles is genuinely ambiguous, and detection must not dress a guess up as +/// evidence. +fn committed_lockfile_pm(dir: &Path, candidates: &[PackageManager]) -> Option { + let names: Vec<&str> = candidates .iter() + .flat_map(|pm| node_lockfiles(*pm)) .copied() - .find(|&pm| node_pm_detector(pm)(dir)) + .collect(); + let tracked = tool::git::tracked(dir, &names)?; + let mut committed = candidates.iter().copied().filter(|pm| { + node_lockfiles(*pm) + .iter() + .any(|lockfile| tracked.iter().any(|path| path == lockfile)) + }); + let only = committed.next()?; + committed.next().is_none().then_some(only) +} + +/// The lockfiles a Node-ecosystem package manager writes. +const fn node_lockfiles(pm: PackageManager) -> &'static [&'static str] { + match pm { + PackageManager::Bun => &["bun.lock", "bun.lockb"], + PackageManager::Pnpm => &["pnpm-lock.yaml"], + PackageManager::Yarn => &["yarn.lock"], + PackageManager::Npm => &["package-lock.json"], + _ => &[], + } } /// Detect package managers by checking for lockfiles and config files. @@ -118,7 +170,7 @@ fn detect_package_managers(dir: &Path, ctx: &mut ProjectContext) { Some(pm) } else if tool::node::has_package_json(dir) { // Read the field with diagnostics so a present-but-unparseable - // value (typo, unsupported PM) doesn't disappear silently — + // value (typo, unsupported PM) doesn't disappear silently; // emit a `DetectionWarning::UnparseablePackageManager` so the // user sees the raw value they wrote and can fix it. let (field_pm, unparseable) = tool::node::detect_pm_field_with_diagnostics(dir); @@ -163,13 +215,13 @@ fn detect_package_managers(dir: &Path, ctx: &mut ProjectContext) { } } -/// Walk upward — workspace-root-aware and VCS-bounded — for the package +/// Walk upward, workspace-root-aware and VCS-bounded, for the package /// manager that governs a manifest-less (or PM-less) workspace member: /// the nearest ancestor Node lockfile, else the nearest ancestor /// manifest's `packageManager`/`devEngines` declaration. /// /// Returns `None` outside a JS workspace so an unrelated outer-project -/// lockfile is never adopted — the same guard that gates upward script +/// lockfile is never adopted, the same guard that gates upward script /// discovery, applied to PM resolution. fn detect_node_pm_upwards(dir: &Path) -> Option { if !tool::node::within_workspace_upwards(dir) { @@ -184,7 +236,7 @@ fn detect_node_pm_upwards(dir: &Path) -> Option { }) } -/// Walk upward — VCS-bounded — for the Python package manager governing +/// Walk upward, VCS-bounded, for the Python package manager governing /// a nested project directory. Nearest ancestor wins; within one directory /// keep the same uv > poetry > pipenv priority as local detection. fn detect_python_pm_upwards(dir: &Path) -> Option { @@ -574,7 +626,7 @@ fn push_described_tasks( /// Append `package.json` scripts, classifying each entry as a /// passthrough wrapper iff its command body literally invokes a known /// task runner against a same-named target (turbo, just, make, task, -/// nx, bacon, mise). Detection is purely textual — the surrounding +/// nx, bacon, mise). Detection is purely textual; the surrounding /// project state is not consulted, so a real script like /// `"build": "vite build"` is never flagged regardless of what other /// sources exist. @@ -629,7 +681,7 @@ type RecipeOrAlias = (String, Option, Option); /// Push `(name, description, alias_of)` triples into `ctx.tasks` under /// `source`, or record a `TaskListUnreadable` warning on error. Shared -/// by [`push_mise_tasks`] and [`push_just_tasks`] — both runners emit +/// by [`push_mise_tasks`] and [`push_just_tasks`], both runners emit /// recipe-or-alias variants that flatten to the same triple shape. fn push_recipe_alias_tasks( ctx: &mut ProjectContext, @@ -659,10 +711,13 @@ fn push_recipe_alias_tasks( #[cfg(test)] mod tests { use std::fs; + use std::path::Path; + use std::process::{Command, Stdio}; use super::parse_tool_versions_node; use crate::detect::detect; use crate::tool::test_support::TempDir; + use crate::types::PackageManager; #[test] fn parses_tool_versions_node_entry() { @@ -696,7 +751,7 @@ mod tests { #[test] fn detect_records_warning_for_unparseable_package_manager_field() { // The user typo'd `pnpm` → `pnpmm`. The resolver can't dispatch - // through `pnpmm@9`, so the manifest declaration is ignored — + // through `pnpmm@9`, so the manifest declaration is ignored, // but the detection layer surfaces the raw value verbatim so // the user sees their typo instead of staring at a doctor // report that just shows `manifest_pm: null`. @@ -815,10 +870,7 @@ mod tests { let ctx = detect(dir.path()); - assert!( - ctx.package_managers - .contains(&crate::types::PackageManager::Uv) - ); + assert!(ctx.package_managers.contains(&PackageManager::Uv)); let names: Vec<&str> = ctx .tasks .iter() @@ -850,7 +902,7 @@ mod tests { let ctx = detect(&nested); - assert_eq!(ctx.package_managers, [crate::types::PackageManager::Uv]); + assert_eq!(ctx.package_managers, [PackageManager::Uv]); assert!(ctx.tasks.iter().any(|task| { task.source == crate::types::TaskSource::PyprojectScripts && task.name == "greenpy" })); @@ -889,18 +941,15 @@ mod tests { let ctx = detect(dir.path()); - assert!( - ctx.package_managers - .contains(&crate::types::PackageManager::Poetry) - ); + assert!(ctx.package_managers.contains(&PackageManager::Poetry)); assert!(ctx.tasks.iter().any(|t| { t.source == crate::types::TaskSource::PyprojectScripts && t.name == "cli" })); } #[test] - fn node_modules_collision_detected_for_bun_plus_deno_node_modules_dir() { - use crate::types::{DetectionWarning, PackageManager}; + fn node_modules_writers_recorded_for_bun_plus_deno_node_modules_dir() { + use crate::types::PackageManager; let dir = TempDir::new("detect-collision"); fs::write(dir.path().join("package.json"), r#"{"name":"x"}"#).expect("package.json"); fs::write(dir.path().join("bun.lock"), "").expect("bun.lock"); @@ -911,31 +960,172 @@ mod tests { .expect("deno.jsonc"); let ctx = detect(dir.path()); - let collision = ctx.warnings.iter().find_map(|w| match w { - DetectionWarning::InstallDirCollision { dir, pms } => Some((*dir, pms.clone())), - _ => None, - }); - let (cdir, pms) = collision.expect("bun + node_modules-dir deno must collide"); - assert_eq!(cdir, "node_modules"); - assert_eq!(pms, vec![PackageManager::Bun, PackageManager::Deno]); + + let writers = ctx + .install_dirs + .iter() + .find(|entry| entry.dir == "node_modules") + .map(|entry| entry.writers.clone()) + .expect("bun + node_modules-dir deno both write node_modules"); + assert_eq!(writers, vec![PackageManager::Bun, PackageManager::Deno]); + // A shared directory is a fact; whether it is a collision depends on the + // install set, which detection knows nothing about. That is now enforced + // by the type system: there is no collision variant on `DetectionWarning` + // for detection to emit. The install planner owns that verdict. } #[test] - fn no_collision_when_deno_keeps_deps_in_global_cache() { - use crate::types::DetectionWarning; + fn deno_is_no_node_modules_writer_when_it_opts_out_of_a_local_tree() { let dir = TempDir::new("detect-no-collision"); fs::write(dir.path().join("package.json"), r#"{"name":"x"}"#).expect("package.json"); fs::write(dir.path().join("bun.lock"), "").expect("bun.lock"); - // No nodeModulesDir → deno uses its global cache, no node_modules. + // Explicit `none` overrides the package.json default of `manual`, so + // deno resolves npm packages from its global cache. + fs::write( + dir.path().join("deno.jsonc"), + r#"{ "nodeModulesDir": "none" }"#, + ) + .expect("deno.jsonc"); + + let ctx = detect(dir.path()); + + let writers = ctx + .install_dirs + .iter() + .find(|entry| entry.dir == "node_modules") + .map(|entry| entry.writers.clone()) + .expect("bun still writes node_modules"); + assert_eq!(writers, vec![PackageManager::Bun]); + } + + #[test] + fn a_deno_project_with_a_package_json_writes_node_modules_without_being_told_to() { + // The shape that used to slip through: no `nodeModulesDir` line at all, + // so runner said deno kept its deps in the global cache, while + // `deno install` was in fact populating node_modules alongside bun. + let dir = TempDir::new("detect-implicit-collision"); + fs::write(dir.path().join("package.json"), r#"{"name":"x"}"#).expect("package.json"); + fs::write(dir.path().join("bun.lock"), "").expect("bun.lock"); fs::write(dir.path().join("deno.jsonc"), r#"{ "tasks": {} }"#).expect("deno.jsonc"); let ctx = detect(dir.path()); - assert!( - !ctx.warnings - .iter() - .any(|w| matches!(w, DetectionWarning::InstallDirCollision { .. })), - "deno without nodeModulesDir must not collide" - ); + + let writers = ctx + .install_dirs + .iter() + .find(|entry| entry.dir == "node_modules") + .map(|entry| entry.writers.clone()) + .expect("both write node_modules"); + assert_eq!(writers, vec![PackageManager::Bun, PackageManager::Deno]); + } + + /// `git init` + commit the named files. Returns false only when git is + /// unavailable, so the caller can skip rather than fail. + fn commit_in(dir: &Path, files: &[&str]) -> bool { + let available = Command::new("git") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()); + if !available { + return false; + } + + let git = |args: &[&str]| { + let status = Command::new("git") + .args(args) + .current_dir(dir) + .env("GIT_AUTHOR_NAME", "t") + .env("GIT_AUTHOR_EMAIL", "t@t") + .env("GIT_COMMITTER_NAME", "t") + .env("GIT_COMMITTER_EMAIL", "t@t") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap_or_else(|err| panic!("failed to run `git {}`: {err}", args.join(" "))); + assert!( + status.success(), + "`git {}` failed with {status}", + args.join(" "), + ); + }; + git(&["init"]); + let mut add = vec!["add"]; + add.extend_from_slice(files); + git(&add); + git(&["commit", "-m", "lockfiles"]); + true + } + + /// A project carrying two node lockfiles. + fn two_lockfiles(name: &str) -> TempDir { + let dir = TempDir::new(name); + fs::write(dir.path().join("package.json"), r#"{"name":"x"}"#).expect("package.json"); + fs::write(dir.path().join("bun.lock"), "").expect("bun.lock"); + fs::write(dir.path().join("package-lock.json"), "{}").expect("package-lock.json"); + dir + } + + #[test] + fn the_committed_lockfile_wins_over_the_preference_order() { + // npm's lockfile is committed and bun's is not, so this is an npm + // project, even though bun outranks npm in NODE_PROBE_ORDER. + let dir = two_lockfiles("detect-committed-npm"); + fs::write(dir.path().join(".gitignore"), "bun.lock\n").expect(".gitignore"); + if !commit_in( + dir.path(), + &["package-lock.json", ".gitignore", "package.json"], + ) { + eprintln!("skipping: git unavailable"); + return; + } + + let ctx = detect(dir.path()); + assert_eq!(ctx.package_managers, vec![PackageManager::Npm]); + } + + #[test] + fn an_ignored_lockfile_still_wins_when_it_is_the_only_one() { + // Ignoring a lockfile is a policy about the repository, not a statement + // that the manager is unused. With nothing to disambiguate, the + // lockfile that exists is the answer. + let dir = TempDir::new("detect-ignored-only"); + fs::write(dir.path().join("package.json"), r#"{"name":"x"}"#).expect("package.json"); + fs::write(dir.path().join("bun.lock"), "").expect("bun.lock"); + fs::write(dir.path().join(".gitignore"), "bun.lock\n").expect(".gitignore"); + if !commit_in(dir.path(), &[".gitignore", "package.json"]) { + eprintln!("skipping: git unavailable"); + return; + } + + let ctx = detect(dir.path()); + assert_eq!(ctx.package_managers, vec![PackageManager::Bun]); + } + + #[test] + fn two_committed_lockfiles_fall_back_to_the_preference_order() { + // A repository that commits both is genuinely ambiguous. Detection + // picks by preference rather than pretending to have evidence. + let dir = two_lockfiles("detect-both-committed"); + if !commit_in( + dir.path(), + &["bun.lock", "package-lock.json", "package.json"], + ) { + eprintln!("skipping: git unavailable"); + return; + } + + let ctx = detect(dir.path()); + assert_eq!(ctx.package_managers, vec![PackageManager::Bun]); + } + + #[test] + fn outside_a_repository_the_preference_order_decides() { + let dir = two_lockfiles("detect-no-git"); + + let ctx = detect(dir.path()); + assert_eq!(ctx.package_managers, vec![PackageManager::Bun]); } #[test] @@ -954,7 +1144,7 @@ mod tests { let ctx = detect(dir.path()); - assert_eq!(ctx.package_managers, [crate::types::PackageManager::Deno]); + assert_eq!(ctx.package_managers, [PackageManager::Deno]); assert!(ctx.tasks.iter().any( |task| task.source == crate::types::TaskSource::PackageJson && task.name == "build" )); @@ -983,10 +1173,7 @@ mod tests { let ctx = detect(&nested); - assert!( - ctx.package_managers - .contains(&crate::types::PackageManager::Deno) - ); + assert!(ctx.package_managers.contains(&PackageManager::Deno)); assert!(ctx.tasks.iter().any(|task| task.name == "member")); assert!(ctx.tasks.iter().any(|task| task.name == "root")); } @@ -1082,7 +1269,7 @@ mod tests { let ctx = detect(dir.path()); - assert_eq!(ctx.package_managers, [crate::types::PackageManager::Pnpm]); + assert_eq!(ctx.package_managers, [PackageManager::Pnpm]); assert!(ctx.tasks.iter().any( |task| task.source == crate::types::TaskSource::PackageJson && task.name == "build" )); @@ -1116,7 +1303,7 @@ mod tests { let ctx = detect(&member); - assert_eq!(ctx.package_managers, [crate::types::PackageManager::Pnpm]); + assert_eq!(ctx.package_managers, [PackageManager::Pnpm]); assert!(ctx.tasks.iter().any( |task| task.source == crate::types::TaskSource::PackageJson && task.name == "build" )); diff --git a/src/lib.rs b/src/lib.rs index 642dfb76..ed957bd7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,9 +92,9 @@ pub fn config_schema() -> schemars::Schema { } /// Exit code semantics: -/// - `0` — success -/// - `1` — generic failure (I/O, detection, child-process non-zero) -/// - `2` — resolver could not satisfy intent (typed resolver error) +/// - `0`, success +/// - `1`, generic failure (I/O, detection, child-process non-zero) +/// - `2`, resolver could not satisfy intent (typed resolver error) /// /// `main` and `bin/run.rs` use this to map an [`anyhow::Error`] to the /// right code: anything that downcasts to the internal resolver-error @@ -187,7 +187,7 @@ where Err(err) => return render_clap_error(&err), }; // The language server parses each editor buffer itself and needs neither a - // resolved project dir nor detection — handle it before either can bail. + // resolved project dir nor detection; handle it before either can bail. #[cfg(feature = "lsp")] if matches!(cli.command.as_ref(), Some(cli::Command::Lsp)) { return cmd::lsp::run(); @@ -241,7 +241,7 @@ fn shorten_help_subcommand(mut command: clap::Command) -> clap::Command { /// dispatch, and return the exit code. /// /// Always treats positional arguments as a task or command (routed through -/// `cmd::run`) — built-in subcommand names are never parsed specially, so +/// `cmd::run`); built-in subcommand names are never parsed specially, so /// `run clean`, `run install`, etc. run a same-named project task when one /// exists. When no such task exists, a bare run token naming a built-in verb /// (`install`/`clean`/`list`/`info`/`completions`) falls back to that @@ -315,7 +315,7 @@ where // clap's built-ins are disabled and the flag is undefined, so it // can't fill the hyphen-rejecting `task` positional and surfaces as // `UnknownArgument`. (A *trailing* one is swallowed by `args` and - // forwarded instead — see `cli::RunAliasCli`.) Covers the bare + // forwarded instead, see `cli::RunAliasCli`.) Covers the bare // `run --help` as well as `run --pm npm --help`, `run --dir … -V`. Err(err) => { return match alias_builtin_request(&err) { @@ -352,8 +352,8 @@ enum AliasBuiltin { /// With clap's built-in `--help`/`--version` disabled and undefined, a /// leading `-h`/`--help`/`-V`/`--version` cannot fill the hyphen-rejecting /// `task` positional, so clap reports [`ErrorKind::UnknownArgument`] naming -/// the offending flag. A *trailing* one never reaches here — it is captured -/// by `args` and forwarded — so an `UnknownArgument` naming a help/version +/// the offending flag. A *trailing* one never reaches here; it is captured +/// by `args` and forwarded, so an `UnknownArgument` naming a help/version /// flag unambiguously means "before any task", i.e. ours to handle. fn alias_builtin_request(err: &clap::Error) -> Option { use clap::error::{ContextKind, ContextValue, ErrorKind}; @@ -416,14 +416,14 @@ where /// `command: None`, reproducing the bare-`runner` project dashboard. /// A lone `-k`/`-K` does not defeat this: the chain-failure flags are /// inert on the dashboard (which never reads the failure policy) and are -/// dropped before override building, so — unlike the old eager builder — +/// dropped before override building, so, unlike the old eager builder, /// a bare `run -k`/`-K` no longer conflicts with an opposite-polarity /// `RUNNER_KILL_ON_FAIL`/`RUNNER_KEEP_GOING` or `[chain]` config; /// - everything else becomes [`cli::Command::Run`] carrying the alias's /// task, forwarded args, and chain flags. /// -/// Building a typed [`cli::Cli`] here — rather than rewriting argv to -/// `["runner", "run", …]` and re-parsing through clap — keeps the mapping +/// Building a typed [`cli::Cli`] here, rather than rewriting argv to +/// `["runner", "run", …]` and re-parsing through clap, keeps the mapping /// total and compiler-checked and, crucially, leaves the alias's bespoke /// help/version forwarding untouched. That forwarding lives in the parse /// layer ([`cli::RunAliasCli`] disables clap's `--help`/`--version` so a @@ -702,7 +702,7 @@ fn dispatch_install_chain( // Pre-flight the task tokens up front to preserve the "typo aborts before // install" guarantee the sequential path gets for free. `run_chain` below // re-prechecks (it can't assume a caller did), but that pass runs after - // install — this loop is what gates the slow install. precheck_task is + // install; this loop is what gates the slow install. precheck_task is // side-effect-free, so the redundant second pass is harmless. for task in tasks { cmd::run::precheck_task(ctx, overrides, task)?; @@ -780,7 +780,7 @@ fn dispatch_run( /// Run-path fallback for builtin verbs. /// /// When a bare, arg-less `run`/`runner run` token names a built-in verb and -/// no same-named task exists, run that built-in's default (no-flag) form — +/// no same-named task exists, run that built-in's default (no-flag) form, /// the same behavior the explicit `runner ` subcommand provides. A /// project task of the same name takes precedence (handled by the early /// `has_task` return → falls through to `cmd::run`). @@ -789,10 +789,10 @@ fn dispatch_run( /// to fall through to `cmd::run` (task dispatch / PM-exec). /// /// Qualified tokens (`source:verb`) carry the `source:` prefix, so they never -/// match a bare verb arm and fall through untouched — no qualifier parsing +/// match a bare verb arm and fall through untouched, no qualifier parsing /// needed here. `info` maps to a plain `list` (no deprecation warning): the /// deprecation is specific to the explicit `runner info` subcommand, and -/// emitting it on the run path — where the user typed `run info` — would be +/// emitting it on the run path, where the user typed `run info`, would be /// misleading and would spuriously fire the GitHub Actions annotation. fn run_path_builtin_fallback( ctx: &types::ProjectContext, @@ -927,7 +927,7 @@ fn build_overrides_lenient( /// Resolve overrides for [`dispatch`]. Strict for every command; /// `doctor` retries leniently on failure because it must survive the -/// misconfigured environment it exists to diagnose — env garbage +/// misconfigured environment it exists to diagnose; env garbage /// degrades to warnings appended to `ctx`, while CLI flag garbage /// re-raises from the lenient pass and stays fatal. fn dispatch_overrides( @@ -948,7 +948,7 @@ fn dispatch_overrides( fn dispatch(cli: cli::Cli, dir: &Path) -> Result { let mut ctx = detect::detect(dir); - // A malformed `runner.toml` must not abort the `config` subcommand — + // A malformed `runner.toml` must not abort the `config` subcommand; // `config validate`/`show` exist to inspect and repair exactly that // file, and they re-load it with their own error handling. Unknown // sections/fields are tolerated everywhere (forward compat) and surface @@ -962,7 +962,10 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { if let Some(loaded) = &loaded_config { ctx.warnings.extend(loaded.warnings.iter().cloned()); } - let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?; + let mut overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?; + // The first point where a resolved root and the inherited marker are both + // in hand, so it is where the nesting question gets answered. + overrides.parent_warned = cmd::parent_warned_about(&ctx.root); match cli.command { None => cmd::info(&ctx, &overrides, false).map(|()| 0), @@ -1011,7 +1014,7 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { failure, .. }) => { - // No post-install tasks, so the chain flags govern nothing — say so + // No post-install tasks, so the chain flags govern nothing; say so // rather than silently swallowing a `-p`/`-k` the user expected to // matter. if mode.sequential || mode.parallel || failure.keep_going || failure.kill_on_fail { @@ -1319,7 +1322,7 @@ mod tests { #[test] fn bin_name_from_arg0_preserves_unrelated_extensions() { - // `.exe` only — names that happen to embed those characters in other + // `.exe` only, names that happen to embed those characters in other // positions, or carry different extensions, pass through unchanged. let dotted = bin_name_from_arg0(&OsString::from("/tmp/runner.exe.bak")); assert_eq!(dotted.as_deref(), Some("runner.exe.bak")); @@ -1355,6 +1358,7 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } @@ -1441,7 +1445,7 @@ mod tests { #[test] fn run_alias_bare_matches_bare_runner_dashboard() { // A bare `run` maps to `command: None`, the same project-dashboard - // path bare `runner` takes — both succeed identically on an empty + // path bare `runner` takes; both succeed identically on an empty // directory. let dir = TempDir::new("runner-run-alias-bare-eq"); let alias = run_alias_in_dir(["run"], dir.path()).expect("bare run should succeed"); diff --git a/src/main.rs b/src/main.rs index 9bc09d24..cc8a8ec1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,7 @@ /// Entry point. /// /// Maps a returned [`anyhow::Error`] to the right exit code via -/// [`runner::exit_code_for_error`] — `ResolveError` → 2, everything else +/// [`runner::exit_code_for_error`], `ResolveError` → 2, everything else /// → 1. The default `Termination` impl on `anyhow::Error` collapses /// every failure to 1 with the same printed format, which is what was /// happening before this binary started caring about resolver-specific diff --git a/src/resolver/error.rs b/src/resolver/error.rs index 563e5e76..8c3b4aee 100644 --- a/src/resolver/error.rs +++ b/src/resolver/error.rs @@ -9,7 +9,7 @@ //! //! Wherever a caller wants to bubble up through `anyhow`, the variant //! converts automatically because `ResolveError` implements -//! `std::error::Error` — `?` works, and `main` recovers the variant via +//! `std::error::Error`, `?` works, and `main` recovers the variant via //! `err.downcast_ref::()` to decide the exit code. use std::fmt; @@ -39,7 +39,7 @@ pub(crate) enum ResolveError { soft: bool, }, /// `devEngines.packageManager` `onFail = error` rejected the - /// installed environment — either the declared binary is missing or + /// installed environment: either the declared binary is missing or /// its version doesn't satisfy the declared range. DevEnginesFailHard { /// The PM the manifest declared. @@ -61,7 +61,7 @@ pub(crate) enum ResolveError { lockfile: PackageManager, }, /// A user-supplied override (CLI flag, env var, or config) names a - /// PM that can't satisfy the requested resolution — e.g. `--pm cargo` + /// PM that can't satisfy the requested resolution, e.g. `--pm cargo` /// when the call is dispatching a `package.json` script. Phase B5 /// will start emitting this; B2 introduces the variant. InvalidOverride { @@ -101,6 +101,14 @@ pub(crate) enum ResolveError { /// `"[chain] config"`, or `"cross-source"`. source: &'static str, }, + /// `[install].on_collision = "error"` and the install set holds two or + /// more package managers that write the same directory. + InstallDirCollision { + /// The shared directory, e.g. `"node_modules"`. + dir: &'static str, + /// The colliding writers, in detection order. + writers: Vec, + }, } /// Why a `devEngines.packageManager` `onFail = error` check rejected the @@ -209,10 +217,27 @@ impl fmt::Display for ResolveError { `[chain].keep_going` or `--kill-on-fail` / `RUNNER_KILL_ON_FAIL` / \ `[chain].kill_on_fail` to pick a policy.", ), + Self::InstallDirCollision { dir, writers } => { + write!(f, "{}", install_dir_collision(dir, writers)) + } } } } +fn install_dir_collision(dir: &str, writers: &[PackageManager]) -> String { + let list = writers + .iter() + .map(|pm| pm.label()) + .collect::>() + .join(", "); + let first = writers.first().map_or("bun", |pm| pm.label()); + format!( + "{list} all install into {dir}/ and `[install].on_collision = \"error\"` refuses to run \ + two writers over one tree. Pick one with `[install].pms = [\"{first}\"]` (or \ + `RUNNER_INSTALL_PMS`), or drop `on_collision` to let runner resolve it.", + ) +} + impl std::error::Error for ResolveError {} #[cfg(test)] diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index 0c3dec25..05bfbfbe 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -2,33 +2,32 @@ //! //! The resolver consumes a [`ProjectContext`] (signals discovered during //! detection) plus a [`ResolutionOverrides`] bundle (CLI flags, env vars, -//! and — in later phases — a `runner.toml`) and returns a single decision +//! and, in later phases, a `runner.toml`) and returns a single decision //! tagged with the chain step that produced it. //! //! Chain order (lower wins): //! -//! 1. Qualified syntax (`turbo.json:build`) — handled in `cmd::run` today. +//! 1. Qualified syntax (`turbo.json:build`), handled in `cmd::run` today. //! 2. CLI flag (`--pm`, `--runner`). //! 3. Environment variable (`RUNNER_PM`, `RUNNER_RUNNER`). -//! 4. Project config (`./runner.toml`) — Phase 3. -//! 5. Manifest declaration (`packageManager`, `devEngines.packageManager`) -//! — Phase 4. +//! 4. Project config (`./runner.toml`), Phase 3. +//! 5. Manifest declaration (`packageManager`, `devEngines.packageManager`), Phase 4. //! 6. Lockfile (current behavior; lives in [`crate::detect`]). -//! 7. `PATH` probe in canonical order — Phase 5. -//! 8. Terminal — error with actionable guidance — Phase 5. +//! 7. `PATH` probe in canonical order, Phase 5. +//! 8. Terminal, error with actionable guidance, Phase 5. //! # Module layout //! -//! - [`types`] — data shapes ([`Resolver`], [`ResolutionOverrides`], the +//! - [`types`], data shapes ([`Resolver`], [`ResolutionOverrides`], the //! policy enums, override-builder helpers). -//! - [`resolve`] — the resolution algorithm itself: `impl Resolver` plus +//! - [`resolve`], the resolution algorithm itself: `impl Resolver` plus //! the manifest / lockfile cross-checks. -//! - [`overrides`] — `impl ResolutionOverrides` and the CLI/env parsers +//! - [`overrides`], `impl ResolutionOverrides` and the CLI/env parsers //! that feed it. -//! - [`policies`] — pure string→enum parsing for the `FallbackPolicy`, +//! - [`policies`], pure string→enum parsing for the `FallbackPolicy`, //! `MismatchPolicy`, and `FailurePolicy` knobs. -//! - [`error`] — the `ResolveError` type surfaced to callers. -//! - [`probe`] — `$PATH` probing for the fallback step. +//! - [`error`], the `ResolveError` type surfaced to callers. +//! - [`probe`], `$PATH` probing for the fallback step. mod error; mod overrides; @@ -54,8 +53,8 @@ pub(crate) use probe::probe_in as probe_path_for_doctor; #[cfg(test)] pub(crate) use types::PmOverride; pub(crate) use types::{ - DiagnosticFlags, FallbackPolicy, MismatchPolicy, OverrideOrigin, ResolutionOverrides, - ResolutionStep, ResolvedPm, Resolver, ScriptPolicy, + CollisionPolicy, DiagnosticFlags, FallbackPolicy, MismatchPolicy, OverrideOrigin, + ResolutionOverrides, ResolutionStep, ResolvedPm, Resolver, ScriptPolicy, }; /// Join an iterator of `&'static str` labels with `", "`. Used by the @@ -89,14 +88,15 @@ mod tests { node_version: None, current_node: None, is_monorepo: false, + install_dirs: Vec::new(), warnings: Vec::new(), } } /// Like [`context`], but rooted in a fresh temp dir instead of `"."`. /// - /// Tests that exercise the manifest-blind steps — lockfile and the - /// fallback policies — must not anchor at the repo checkout: the + /// Tests that exercise the manifest-blind steps, lockfile and the + /// fallback policies, must not anchor at the repo checkout: the /// manifest walk (`detect_pm_from_manifest` / `find_manifest_upwards`) /// starts at `ctx.root`, and runner's own `package.json` declares /// `"packageManager": "bun@…"`, which outranks the synthetic context @@ -239,7 +239,7 @@ mod tests { // routing through `bun`/`pnpm`/`yarn`/`npm`. let dir = TempDir::new("resolver-no-pkgjson"); - // Detected ecosystem signals are non-Node — mirrors what + // Detected ecosystem signals are non-Node, mirrors what // `detect` would produce for a Go project. let mut ctx = context(vec![PackageManager::Go]); ctx.root = dir.path().to_path_buf(); @@ -608,7 +608,7 @@ mod tests { #[test] fn lenient_env_bool_typo_warns_and_is_ignored() { // `RUNNER_KEEP_GOING=flase` (typo'd "false") used to read as - // truthy — the opposite of the user's intent. It must warn and + // truthy, the opposite of the user's intent. It must warn and // stay unset instead. use crate::chain::FailurePolicy; let (overrides, warnings) = ResolutionOverrides::from_sources_lenient(OverrideSources { @@ -1197,7 +1197,7 @@ mod tests { .expect("tasks.prefer of known labels should parse"); // `bun` (a package manager) maps to its package.json source; `turbo` - // (a runner) to turbo.json — proving the unified label vocabulary. + // (a runner) to turbo.json, proving the unified label vocabulary. assert_eq!( overrides.prefer_sources, vec![TaskSource::PackageJson, TaskSource::TurboJson], @@ -1510,7 +1510,7 @@ mod tests { use crate::tool::node::{ManifestPmDecl, ManifestSource, OnFail}; // OnFail=Ignore short-circuits before the binary/version checks - // even run — they should never be called. Use a panicking + // even run; they should never be called. Use a panicking // checker to prove the early return holds. let decl = ManifestPmDecl { pm: PackageManager::Npm, @@ -1536,8 +1536,8 @@ mod tests { use crate::tool::node::{ManifestPmDecl, ManifestSource, OnFail, VersionCheck}; // Version checks that can't run (unparseable range, missing - // --version output) collapse to Unverifiable — that path must - // continue silently, not warn or bail, otherwise a partially + // --version output) collapse to Unverifiable. That path must + // continue silently, not warn or bail; otherwise a partially // broken environment blocks dispatch unnecessarily. let decl = ManifestPmDecl { pm: PackageManager::Yarn, @@ -1681,7 +1681,7 @@ mod tests { // Table-driven: each row pairs a decision with the exact string // it must produce. Locks down the provenance wording that - // `--explain` and `runner why` surface verbatim — a casual + // `--explain` and `runner why` surface verbatim; a casual // re-phrase shouldn't slip through silently. let cases: &[(super::ResolvedPm, &str)] = &[ ( @@ -1857,7 +1857,7 @@ mod tests { #[test] fn from_sources_cli_flag_beats_opposite_config_polarity() { // `-k` must override `[chain] kill_on_fail = true`, not collide - // with it — the config polarity has no CLI negation flag, so a + // with it: the config polarity has no CLI negation flag, so a // cross-source conflict error would make it uncancellable from // the command line. use crate::chain::FailurePolicy; diff --git a/src/resolver/overrides.rs b/src/resolver/overrides.rs index f9fc8da7..5546edf9 100644 --- a/src/resolver/overrides.rs +++ b/src/resolver/overrides.rs @@ -1,4 +1,4 @@ -//! Override construction — `impl ResolutionOverrides` plus the CLI/env +//! Override construction, `impl ResolutionOverrides` plus the CLI/env //! parsers that feed it. Policy parsing lives in [`super::policies`]; //! the data shapes live in [`super::types`]. @@ -8,12 +8,12 @@ use anyhow::{Result, anyhow}; use super::join_labels; use super::policies::{ - is_env_truthy, parse_fallback_label, parse_mismatch_label, parse_prefer_runners, - parse_tasks_overrides, parse_tasks_prefer, resolve_failure_policy, resolve_fallback_policy, - resolve_mismatch_policy, + is_env_truthy, parse_collision_label, parse_fallback_label, parse_mismatch_label, + parse_prefer_runners, parse_tasks_overrides, parse_tasks_prefer, resolve_failure_policy, + resolve_fallback_policy, resolve_mismatch_policy, }; use super::types::{ - DiagnosticFlags, ExplainSource, OverrideOrigin, OverrideSources, PmOverride, + CollisionPolicy, DiagnosticFlags, ExplainSource, OverrideOrigin, OverrideSources, PmOverride, ResolutionOverrides, RunnerOverride, ScriptPolicy, SourceValue, }; use crate::config::{LoadedConfig, parse_node_pm, parse_python_pm}; @@ -56,7 +56,7 @@ impl ResolutionOverrides { } /// Lenient sibling of [`Self::from_cli_and_env`] for commands that - /// must keep working when the *environment* is misconfigured — + /// must keep working when the *environment* is misconfigured. /// `runner doctor` exists to diagnose exactly that, so it can't die /// on the condition it should report. Invalid env-sourced override /// values are blanked and returned as @@ -93,7 +93,7 @@ impl ResolutionOverrides { /// pre-validates every env-sourced string field, blanking invalid /// values into warnings, then delegates to [`Self::from_sources`]. /// - /// Mirrors [`parse_override`] precedence exactly — an env value + /// Mirrors [`parse_override`] precedence exactly: an env value /// shadowed by a CLI value is never parsed by the strict path, so /// it is not validated (or warned about) here either. /// @@ -139,6 +139,12 @@ impl ResolutionOverrides { &mut warnings, |raw| parse_script_policy_label(raw).map(drop), ); + lenient_env_field( + &mut sources.install_on_collision, + "RUNNER_INSTALL_ON_COLLISION", + &mut warnings, + |raw| parse_collision_label(raw).map(drop), + ); for (field, var) in [ (&mut sources.no_warnings, "RUNNER_NO_WARNINGS"), (&mut sources.quiet, "RUNNER_QUIET"), @@ -193,7 +199,7 @@ impl ResolutionOverrides { )?; // `[tasks]` (rank-only, PM-aware) supersedes the deprecated // `[task_runner].prefer` (restrictive, runners-only). When the new - // section carries anything, the legacy list is ignored entirely — the + // section carries anything, the legacy list is ignored entirely; the // config loader has already emitted the deprecation warning. // // "Carries anything" is judged on the *raw* config fields, not the @@ -230,6 +236,7 @@ impl ResolutionOverrides { let parallel_grouped = sources.config.is_some_and(|c| c.config.parallel.grouped); let install_pms = parse_install_pms(&sources)?; let script_policy = parse_install_scripts(&sources)?; + let on_collision = parse_install_on_collision(&sources)?; let mut pm_by_ecosystem = HashMap::new(); if let Some(loaded) = sources.config { @@ -277,6 +284,11 @@ impl ResolutionOverrides { parallel_grouped, install_pms, script_policy, + on_collision, + // Set in `dispatch`, which is the first place a resolved project + // root and the inherited `RUNNER_WARNED_ROOT` marker are both in + // hand. Nothing to capture from `sources`. + parent_warned: false, // Set by a parent runner that already opened a GHA group (see // `crate::cmd::GROUP_ACTIVE_ENV`), captured into `sources` so this // stays a pure function of its inputs. An internal nesting signal, @@ -326,7 +338,7 @@ fn parse_install_pms(sources: &OverrideSources<'_>) -> Result) -> Result Ok(ScriptPolicy::Default) } +/// `RUNNER_INSTALL_ON_COLLISION` (env) → `[install].on_collision` (config), +/// highest first. Absent leaves [`CollisionPolicy::Resolve`]. +fn parse_install_on_collision(sources: &OverrideSources<'_>) -> Result { + if let Some(raw) = sources + .install_on_collision + .env + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return parse_collision_label(raw) + .map_err(|err| anyhow!("RUNNER_INSTALL_ON_COLLISION: {err}")); + } + if let Some(raw) = sources + .config + .and_then(|loaded| loaded.config.install.on_collision.as_deref()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + return parse_collision_label(raw).map_err(|err| anyhow!("[install].on_collision: {err}")); + } + Ok(CollisionPolicy::default()) +} + /// Parse a single `deny`/`allow` script-policy label (case-sensitive, -/// lowercase-only — matching the sibling enum-label parsers and the +/// lowercase-only, matching the sibling enum-label parsers and the /// committed JSON Schema enum). /// /// # Errors @@ -379,15 +414,15 @@ fn parse_script_policy_label(raw: &str) -> Result { }) } -/// Validate a loaded `runner.toml` in isolation — no CLI or environment -/// layer — by running it through the real override builder. Every field is +/// Validate a loaded `runner.toml` in isolation, no CLI or environment +/// layer, by running it through the real override builder. Every field is /// parsed exactly as a live dispatch would parse it (PM names, task-runner /// `prefer` list, `fallback` / `on_mismatch` policies), and the in-file /// `[chain]` failure-policy conflict (`keep_going` and `kill_on_fail` both /// `true`) surfaces here too: with no env var to neutralize a side, the /// same [`ResolveError::ConflictingFailurePolicy`] the resolver raises at /// dispatch time fires during construction. Delegating keeps `config -/// validate` honest — it can never accept a file a real run would reject. +/// validate` honest: it can never accept a file a real run would reject. /// /// # Errors /// @@ -708,6 +743,7 @@ struct EnvSnapshot { kill_on_fail: Option, install_pms: Option, install_scripts: Option, + install_on_collision: Option, group_active: Option, } @@ -727,6 +763,7 @@ impl EnvSnapshot { kill_on_fail: std::env::var("RUNNER_KILL_ON_FAIL").ok(), install_pms: std::env::var("RUNNER_INSTALL_PMS").ok(), install_scripts: std::env::var("RUNNER_INSTALL_SCRIPTS").ok(), + install_on_collision: std::env::var("RUNNER_INSTALL_ON_COLLISION").ok(), group_active: std::env::var(crate::cmd::GROUP_ACTIVE_ENV).ok(), } } @@ -783,6 +820,10 @@ impl EnvSnapshot { cli: None, env: self.install_scripts.as_deref(), }, + install_on_collision: SourceValue { + cli: None, + env: self.install_on_collision.as_deref(), + }, group_active: self.group_active.as_deref(), config, } @@ -791,8 +832,8 @@ impl EnvSnapshot { /// Pre-validate one env-sourced override field for the lenient /// constructor. The env side is only consulted (and therefore only -/// validated) when the CLI side is unset or whitespace-only — exactly -/// the precedence [`parse_override`] applies — so CLI-shadowed env +/// validated) when the CLI side is unset or whitespace-only, exactly +/// the precedence [`parse_override`] applies, so CLI-shadowed env /// garbage stays invisible, same as the strict path. An invalid env /// value is blanked from `field` and reported as a warning carrying /// the sanitized value and the bare parse error. @@ -822,11 +863,11 @@ fn lenient_env_field( /// Boolean counterpart of [`lenient_env_field`]: a `RUNNER_*` toggle /// whose value is not a recognized boolean token warns and is ignored /// instead of silently reading as truthy. Without this, a typo like -/// `RUNNER_KEEP_GOING=flase` turned the knob ON — the opposite of the +/// `RUNNER_KEEP_GOING=flase` turned the knob ON, the opposite of the /// user's clear intent. Recognized (case-insensitive): `1`, `true`, /// `yes`, `on` / `0`, `false`, `no`, `off`; blank stays "unset" per the /// resolver-wide convention. A set CLI flag shadows the env value, so it -/// isn't validated (or warned about) then — mirroring +/// isn't validated (or warned about) then, mirroring /// [`lenient_env_field`]. fn lenient_env_bool( field: &mut ExplainSource<'_>, diff --git a/src/resolver/policies.rs b/src/resolver/policies.rs index 569538e0..5d6355f8 100644 --- a/src/resolver/policies.rs +++ b/src/resolver/policies.rs @@ -1,4 +1,4 @@ -//! Policy parsing — `FallbackPolicy`, `MismatchPolicy`, `FailurePolicy`, +//! Policy parsing, `FallbackPolicy`, `MismatchPolicy`, `FailurePolicy`, //! plus the `[task_runner].prefer` list and the shared `RUNNER_*` env //! parsers. //! @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use anyhow::{Result, anyhow}; -use super::types::{ExplainSource, FallbackPolicy, MismatchPolicy}; +use super::types::{CollisionPolicy, ExplainSource, FallbackPolicy, MismatchPolicy}; use super::{ResolveError, join_labels}; use crate::chain::FailurePolicy; use crate::config::LoadedConfig; @@ -21,7 +21,7 @@ use crate::types::{PackageManager, TaskRunner, TaskSource}; /// Surrounding whitespace is stripped first so a trailing newline (the /// shell-export pattern `RUNNER_EXPLAIN=$VAR \n …`) doesn't accidentally /// flip an explicit "off" into truthy. Without the case-insensitive -/// compare, `RUNNER_EXPLAIN=FALSE` would silently enable the trace — +/// compare, `RUNNER_EXPLAIN=FALSE` would silently enable the trace, /// the opposite of what the user clearly meant. pub(super) fn is_env_truthy(raw: &str) -> bool { let v = raw.trim(); @@ -119,8 +119,8 @@ pub(super) fn parse_prefer_runners(config: Option<&LoadedConfig>) -> Result) -> Result, @@ -213,6 +213,18 @@ pub(super) fn parse_mismatch_label(raw: &str) -> Result { }) } +pub(super) fn parse_collision_label(raw: &str) -> Result { + CollisionPolicy::ALL + .into_iter() + .find(|policy| policy.label() == raw) + .ok_or_else(|| { + anyhow!( + "unknown on-collision policy {raw:?}; expected one of {}", + join_labels(CollisionPolicy::ALL.iter().map(|p| p.label())), + ) + }) +} + pub(super) fn resolve_mismatch_policy( cli: Option<&str>, env: Option<&str>, @@ -240,7 +252,7 @@ pub(super) fn resolve_mismatch_policy( /// Resolve a chain failure policy from CLI/env/config sources. /// -/// `keep_going` and `kill_on_fail` are independent bool layers — CLI flag +/// `keep_going` and `kill_on_fail` are independent bool layers: CLI flag /// presence beats env (explicit either polarity) beats `[chain]` config /// beats `false`. The env layer is presence-authoritative: an explicit /// `RUNNER_KEEP_GOING=0` overrides `[chain].keep_going = true` in config. @@ -249,7 +261,7 @@ pub(super) fn resolve_mismatch_policy( /// is a `ResolveError::ConflictingFailurePolicy`), but *across* layers /// the stronger source wins the whole policy: `-k` on the command line /// beats a `[chain] kill_on_fail = true` in config rather than -/// colliding with it — otherwise a config-pinned polarity would be +/// colliding with it; otherwise a config-pinned polarity would be /// uncancellable from the CLI, contradicting CLI > env > config. pub(super) fn resolve_failure_policy( keep_going: ExplainSource<'_>, @@ -307,7 +319,7 @@ enum ChainBoolLayer { /// The highest-precedence layer that turns a chain knob ON, or `None` /// when no layer does. An explicit env falsy (`RUNNER_*=0`) shadows a -/// config `true` below it — presence-authoritative, matching +/// config `true` below it, presence-authoritative, matching /// [`parse_env_bool`]. fn chain_bool_layer(cli: bool, env: Option, config: Option) -> Option { if cli { diff --git a/src/resolver/probe.rs b/src/resolver/probe.rs index cbd6e582..611a5d0f 100644 --- a/src/resolver/probe.rs +++ b/src/resolver/probe.rs @@ -1,9 +1,9 @@ -//! `PATH` probe — step 7 of the resolution chain. +//! `PATH` probe, step 7 of the resolution chain. //! //! When no manifest, lockfile, or override signal points the resolver at a //! package manager, this module walks `$PATH` (and `PATHEXT` on Windows) //! to discover what is actually installed. The Node ecosystem returns the -//! first match in canonical order — `bun > pnpm > yarn > npm` — matching +//! first match in canonical order, `bun > pnpm > yarn > npm`, matching //! the priority used elsewhere in detection. This replaces the silent //! `npm` fallback the resolver used to default to. //! @@ -23,14 +23,14 @@ use crate::types::PackageManager; /// Process-wide cache of [`probe`] lookups, one slot per /// [`PackageManager`] variant. `OnceLock` initialises lazily and /// guarantees the initialiser runs at most once even when called -/// concurrently — exactly the semantics we want here. +/// concurrently, exactly the semantics we want here. static CACHE: [OnceLock>; PackageManager::COUNT] = [const { OnceLock::new() }; PackageManager::COUNT]; /// Probe `$PATH` for `pm`. Returns the absolute path of the first /// matching executable, or `None` if nothing is found. /// -/// On Windows, also walks `PATHEXT` so that `cmd`/`bat` shims are found — +/// On Windows, also walks `PATHEXT` so that `cmd`/`bat` shims are found, /// the same approach used by [`crate::tool::program::command`]. /// /// Result is memoized in [`CACHE`] for the lifetime of the process. @@ -177,8 +177,8 @@ mod tests { #[test] fn probe_returns_consistent_value_across_calls() { - // Asserts the caller-visible property — repeated `probe(pm)` - // calls return the same cached value — rather than poking at + // Asserts the caller-visible property, repeated `probe(pm)` + // calls return the same cached value, rather than poking at // cache internals, which other tests in this process may have // already populated. use super::probe; diff --git a/src/resolver/resolve.rs b/src/resolver/resolve.rs index cf552d37..77bc884c 100644 --- a/src/resolver/resolve.rs +++ b/src/resolver/resolve.rs @@ -1,6 +1,6 @@ //! Resolution algorithm: the `impl Resolver` block plus the manifest or lockfile cross-checks that feed it. //! -//! Pure logic only — parsing user input lives in [`super::overrides`] and +//! Pure logic only. Parsing user input lives in [`super::overrides`] and //! [`super::policies`]; data types live in [`super::types`]. use super::probe; @@ -24,16 +24,16 @@ impl<'ctx> Resolver<'ctx> { /// Resolve the package manager used to dispatch `package.json` scripts. /// /// Walks the precedence chain in order: - /// - Step 2–3 — CLI/env PM override (when compatible with Node scripts). - /// - Step 4 — `runner.toml` `[pm].node` override. - /// - Step 5a — `package.json` legacy `packageManager` field. - /// - Step 5b — `package.json` `devEngines.packageManager` field + /// - Step 2–3, CLI/env PM override (when compatible with Node scripts). + /// - Step 4, `runner.toml` `[pm].node` override. + /// - Step 5a, `package.json` legacy `packageManager` field. + /// - Step 5b, `package.json` `devEngines.packageManager` field /// (honoring `onFail` when the declared PM is missing from PATH). - /// - Step 6 — lockfile (via [`ProjectContext::primary_node_pm`]). - /// - Step 7 — `$PATH` probe in canonical Node order + /// - Step 6, lockfile (via [`ProjectContext::primary_node_pm`]). + /// - Step 7, `$PATH` probe in canonical Node order /// (`bun > pnpm > yarn > npm`). Active by default; replaced by /// step 8 when `--fallback npm` is set. - /// - Step 8 — error or legacy `npm` (depending on + /// - Step 8, error or legacy `npm` (depending on /// [`FallbackPolicy`]). /// /// When a manifest declaration (step 5) disagrees with a detected @@ -53,7 +53,7 @@ impl<'ctx> Resolver<'ctx> { if !o.pm.can_dispatch_node_scripts() { // The user explicitly pinned a PM that can't dispatch // package.json scripts. Falling through to step 4-7 - // would silently disregard their intent — surface the + // would silently disregard their intent. Surface the // mismatch as a hard error instead. return Err(ResolveError::InvalidOverride { value: o.pm.label().to_string(), @@ -167,19 +167,19 @@ impl<'ctx> Resolver<'ctx> { /// declared PM is present on `$PATH` *and*, when a semver range is /// declared, that the installed version satisfies it. /// -/// - `Ignore` — no check. -/// - `Warn` — emit a `package.json` warning when the PM is missing or +/// - `Ignore`, no check. +/// - `Warn`, emit a `package.json` warning when the PM is missing or /// the version doesn't match; continue with the declared PM regardless. -/// - `Error` — bail on a missing PM or a version mismatch. +/// - `Error`, bail on a missing PM or a version mismatch. /// /// Version checks that can't run (unparseable range, missing /// `--version` output, etc.) are skipped silently: the proposal says /// `onFail` enforces user intent, but blocking dispatch on an -/// unverifiable constraint would be worse than continuing — the binary +/// unverifiable constraint would be worse than continuing; the binary /// will surface the real problem at spawn time. /// /// Binary-presence and version-check side effects are injected so the -/// `Error` branches stay exercisable in unit tests — `Error + missing` +/// `Error` branches stay exercisable in unit tests: `Error + missing` /// and `Error + mismatched version` both `bail!`, which is impossible /// to cover otherwise without controlling the host `$PATH` and running /// ` --version` against a real binary. Production callers wire in @@ -261,7 +261,7 @@ fn on_fail_version_mismatch( } } -/// Soft "no PM found" — only emitted from the `Probe` fallback when +/// Soft "no PM found", only emitted from the `Probe` fallback when /// nothing on `$PATH` matches. Callers that legitimately want to fall /// through to a direct PATH spawn (`cmd::run::run_pm_exec_fallback`) /// match on `ResolveError::NoSignalsFound { soft: true, .. }` and swallow @@ -273,7 +273,7 @@ const fn no_pm_found_soft() -> ResolveError { } } -/// Hard "no PM found" — emitted from `FallbackPolicy::Error`. Carries +/// Hard "no PM found", emitted from `FallbackPolicy::Error`. Carries /// the same payload but with `soft = false`, so `cmd::run::run` /// propagates it instead of falling through. const fn no_pm_found_hard() -> ResolveError { @@ -286,9 +286,9 @@ const fn no_pm_found_hard() -> ResolveError { /// Compare a manifest declaration against the lockfile-signal recorded in /// [`ProjectContext`] and apply the configured [`MismatchPolicy`]. /// -/// - [`MismatchPolicy::Warn`] — push a `PmMismatch` warning, declaration wins. -/// - [`MismatchPolicy::Ignore`] — declaration wins silently. -/// - [`MismatchPolicy::Error`] — bail with +/// - [`MismatchPolicy::Warn`], push a `PmMismatch` warning; declaration wins. +/// - [`MismatchPolicy::Ignore`], declaration wins silently. +/// - [`MismatchPolicy::Error`], bail with /// [`ResolveError::MismatchPolicyError`] so the CLI exits with code 2. /// /// Manifest declarations frequently come from a project intentionally diff --git a/src/resolver/types.rs b/src/resolver/types.rs index 05947218..3aaa51a1 100644 --- a/src/resolver/types.rs +++ b/src/resolver/types.rs @@ -1,6 +1,6 @@ -//! Resolver data types — the public structs/enums + their trivial impls. +//! Resolver data types, the public structs/enums + their trivial impls. //! -//! No resolution logic, no parsing — just the shapes the rest of the +//! No resolution logic, no parsing, just the shapes the rest of the //! resolver passes around. `impl Resolver` lives in [`super::resolve`]; //! `impl ResolutionOverrides` lives in [`super::overrides`]. @@ -102,7 +102,7 @@ pub(crate) struct ResolutionOverrides { pub parallel_grouped: bool, /// Allowlist of package managers `runner install` may run, resolved /// from `RUNNER_INSTALL_PMS` (env) → `[install].pms` (config). Empty - /// means "no install filter" — install fans out to every detected PM. + /// means "no install filter": install fans out to every detected PM. /// Unlike [`Self::pm`], this never affects script dispatch. pub install_pms: Vec, /// Install-time lifecycle-script policy, resolved from @@ -112,14 +112,23 @@ pub(crate) struct ResolutionOverrides { /// boundary; [`ScriptPolicy::Default`] leaves each package manager at its /// own built-in default. pub script_policy: ScriptPolicy, + /// Install-directory collision policy, resolved from + /// `RUNNER_INSTALL_ON_COLLISION` (env) → `[install].on_collision` (config). + pub on_collision: CollisionPolicy, /// `true` when a parent `runner`/`run` already opened a GitHub Actions /// log group above this process (signalled via the inherited - /// `RUNNER_GROUP_ACTIVE` env marker). GitHub Actions groups don't nest — - /// a nested `::endgroup::` closes the parent's group early — so when this + /// `RUNNER_GROUP_ACTIVE` env marker). GitHub Actions groups don't nest: + /// a nested `::endgroup::` closes the parent's group early, so when this /// is set, this runner's own group-opening sites stay silent and output /// flows into the parent's group. Internal/runner-set, never a user /// override. pub parent_group_open: bool, + /// `true` when a parent `runner`/`run` already emitted this project's + /// detection warnings (signalled via the inherited `RUNNER_WARNED_ROOT` + /// env marker, which carries the root it warned about). A script that + /// calls `runner` again would otherwise repeat every warning at each + /// level. Internal/runner-set, never a user override. + pub parent_warned: bool, } /// What to do when no signal in steps 2–6 matches. @@ -164,7 +173,7 @@ impl FallbackPolicy { /// …) are the primary supply-chain attack surface during dependency /// installs. This knob lets a project deny them across the package managers /// that expose a skip mechanism, or force them on across the managers that -/// can express it — the latter matters because several package managers +/// can express it. The latter matters because several package managers /// (npm, pnpm, …) are moving to scripts-off-by-default in upcoming majors. /// /// Set via `--no-scripts` (deny) / `--scripts` (force on) on the CLI, @@ -197,14 +206,14 @@ pub(crate) enum ScriptPolicy { } impl ScriptPolicy { - /// The two labels a user can actually type — `Default` is the + /// The two labels a user can actually type. `Default` is the /// internal "unset" sentinel, never a valid `[install].scripts` / /// `RUNNER_INSTALL_SCRIPTS` value. Single source of truth for /// [`super::overrides::parse_script_policy_label`]. pub(crate) const SETTABLE: [Self; 2] = [Self::Deny, Self::Allow]; /// The user-facing label, or `None` for [`Self::Default`] (never - /// user-settable — see [`Self::SETTABLE`]). + /// user-settable, see [`Self::SETTABLE`]). pub(crate) const fn label(self) -> Option<&'static str> { match self { Self::Default => None, @@ -219,7 +228,7 @@ impl ScriptPolicy { /// /// Set via `--on-mismatch` / `RUNNER_ON_MISMATCH` / /// `[resolution].on_mismatch`. Independent from -/// `devEngines.packageManager` `onFail` — that policy governs whether +/// `devEngines.packageManager` `onFail`. That policy governs whether /// the *declared* PM can actually run; this one governs whether the /// resolver tolerates the declaration disagreeing with the install /// state at all. @@ -227,14 +236,14 @@ impl ScriptPolicy { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "kebab-case")] pub(crate) enum MismatchPolicy { - /// Emit a `package.json` warning, prefer the declaration (Corepack - /// semantics — the lockfile is most likely stale). + /// Emit a `package.json` warning; prefer the declaration (Corepack + /// semantics, the lockfile is most likely stale). #[default] Warn, /// Stay silent; prefer the declaration. Ignore, - /// Bail with [`super::ResolveError::MismatchPolicyError`]. Intended for - /// CI guardrails where a mismatch should block the run. + /// Refuse to run and exit non-zero. Intended for CI guardrails where a + /// mismatch should block the run. Error, } @@ -256,6 +265,42 @@ impl MismatchPolicy { } } +/// How `runner install` reacts when two or more package managers in the +/// install set write the same directory (a node PM plus a +/// `nodeModulesDir`-enabled Deno both materializing `node_modules/`). +/// +/// Set via `[install].on_collision` / `RUNNER_INSTALL_ON_COLLISION`. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum CollisionPolicy { + /// Install with one writer per directory and shadow the rest, the same + /// way a duplicate task name resolves to one source. An explicit + /// `[install].pms` naming several writers is consent: they all run, + /// serialized over the shared tree. + #[default] + Resolve, + /// Refuse to install and exit non-zero rather than pick. For CI + /// guardrails that want an ambiguous tree to block the run. + Error, +} + +impl CollisionPolicy { + /// Every variant, in the order [`Self::label`]'s callers should list + /// them. Single source of truth for + /// [`super::policies::parse_collision_label`] and any surface that + /// needs to advertise or validate against the same closed set. + pub(crate) const ALL: [Self; 2] = [Self::Resolve, Self::Error]; + + /// The `[install].on_collision` / `RUNNER_INSTALL_ON_COLLISION` label. + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Resolve => "resolve", + Self::Error => "error", + } + } +} + /// A package-manager override plus the source the user set it from. #[derive(Debug, Clone)] pub(crate) struct PmOverride { @@ -322,7 +367,7 @@ pub(crate) struct ResolvedPm { /// Surfaced by [`Self::describe`] for `--explain` and the /// `doctor` / `why` traces. pub via: ResolutionStep, - /// Non-fatal warnings emitted while resolving — e.g. a manifest + /// Non-fatal warnings emitted while resolving, e.g. a manifest /// declaration that disagrees with the detected lockfile. pub warnings: Vec, } @@ -333,27 +378,27 @@ pub(crate) struct ResolvedPm { /// that adding a step is a compile error to handle. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ResolutionStep { - /// Steps 2–4 — user-supplied override won. + /// Steps 2–4, user-supplied override won. Override(OverrideOrigin), - /// Step 5a — `package.json` legacy `packageManager` field. + /// Step 5a, `package.json` legacy `packageManager` field. ManifestPackageManager, - /// Step 5b — `package.json` `devEngines.packageManager` field. + /// Step 5b, `package.json` `devEngines.packageManager` field. ManifestDevEngines { /// Effective `onFail` value for the chosen entry. Rendered into /// the `--explain` / `doctor` trace via [`ResolvedPm::describe`]. on_fail: OnFail, }, - /// Step 6 — package manager inferred from a lockfile (or another + /// Step 6, package manager inferred from a lockfile (or another /// detector recorded in [`ProjectContext::package_managers`]). Lockfile, - /// Step 7 — discovered via `$PATH` probe in canonical order. + /// Step 7, discovered via `$PATH` probe in canonical order. PathProbe { /// Absolute path of the executable found on PATH. Rendered by /// [`ResolvedPm::describe`] so the user can spot which directory /// the resolver fell back to. binary: PathBuf, }, - /// Step 8 (legacy) — no signals matched, default to `npm` so that + /// Step 8 (legacy), no signals matched; default to `npm` so that /// `runner run