From 3521f4b7dfb45549162dffe510f9e1e0b1b4e212 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 11 Jun 2026 02:13:26 +0200 Subject: [PATCH 1/8] fix(types): evaluate node version ranges with real semver semantics `version_matches` stripped range operators and prefix-matched, so `>=22.22.2` behaved as `=22.22.2` and warned on Node 22.22.3/25.9.0. Normalize node-semver grammar (space-AND, `||` unions, hyphen ranges, detached operators, `v` prefixes) into the comma grammar the existing `semver` dependency parses, and delegate evaluation to it. Bare versions keep prefix-at-segment-boundary semantics (a `.nvmrc` `20.11` means 20.11.x, narrower than the crate's caret default); unevaluable input falls back to the historical prefix match. --- CHANGELOG.md | 9 ++ src/types.rs | 273 ++++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 268 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7c4d25..305fecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Fixed +- Node version constraints are now evaluated with real range semantics + (via the `semver` crate) instead of a prefix match that treated + `>=22.22.2` as `=22.22.2`. Operators (`>=`, `>`, `<=`, `<`, `=`), + caret/tilde ranges, space-separated AND comparators, `||` unions, + hyphen ranges, and `x` wildcards all match per node-semver rules, so + `engines.node: ">=22.22.2"` no longer warns on Node 22.22.3 or 25.9.0. + Bare versions (`.nvmrc` `20.11`) keep the stricter + prefix-at-segment-boundary behavior; unevaluable inputs (`lts/*`) fall + back to the previous prefix match. - The no-argument project-info banner no longer leaks the Windows `.exe` suffix in its title line (e.g. `run.exe 0.12.2`). It now shows the same `run` / `runner` identity as `--version`, `--help`, and the `Usage:` diff --git a/src/types.rs b/src/types.rs index 113ae49..184ff99 100644 --- a/src/types.rs +++ b/src/types.rs @@ -643,18 +643,45 @@ impl TaskSource { } } -/// Loose semver prefix match. +/// Does `current` satisfy the `expected` version constraint? /// -/// Strips leading range operators (`>=`, `~`, `^`, etc.) and checks whether -/// `current` starts with the cleaned `expected` value. A bare major version -/// like `"20"` matches `"20.x.y"`. +/// `expected` accepts the node-semver range grammar found in `.nvmrc`, +/// `.node-version`, `.tool-versions`, and `package.json` `engines.node`: +/// comparator sets (`>=22.22.2`, `>=18 <21`), caret/tilde ranges +/// (`^20.11`, `~18.15`), `||` unions, hyphen ranges (`18 - 20`), and +/// wildcards (`20.x`). Evaluation is delegated to the `semver` crate +/// after normalizing node's grammar into the comma-separated comparator +/// form it parses. /// -/// This intentionally ignores operator semantics — use the `semver` crate -/// for precise constraint evaluation. +/// Bare versions (`"20"`, `"20.11"`) keep prefix-at-segment-boundary +/// semantics — a `.nvmrc` saying `20.11` means "any 20.11.x", which is +/// narrower than the caret default the `semver` crate would apply. +/// +/// Anything unevaluable (`lts/*`, malformed ranges, a non-version +/// `current`) falls back to the historical prefix match, so this never +/// panics and never rejects input it used to accept. +/// +/// A prerelease `current` (e.g. `23.0.0-nightly`) only matches a +/// comparator that pins the same triple with a prerelease tag — the +/// `semver` crate's gate, mirroring node-semver's default behavior. pub(crate) fn version_matches(expected: &str, current: &str) -> bool { let expected = expected.trim(); let current = current.trim(); + if bare_version(expected) { + return prefix_version_matches(expected, current); + } + range_matches(expected, current).unwrap_or_else(|| prefix_version_matches(expected, current)) +} + +/// The historical loose prefix match, kept as the fallback for inputs +/// the range path can't evaluate and as the primary semantics for bare +/// versions. +/// +/// Strips leading range operators (`>=`, `~`, `^`, etc.) and checks +/// whether `current` starts with the cleaned `expected` value at a +/// segment boundary. A bare major version like `"20"` matches `"20.x.y"`. +fn prefix_version_matches(expected: &str, current: &str) -> bool { let expected_clean = expected .trim_start_matches(">=") .trim_start_matches("<=") @@ -665,14 +692,6 @@ pub(crate) fn version_matches(expected: &str, current: &str) -> bool { .trim_start_matches('v') .trim(); - if !expected_clean.contains('.') { - return current.starts_with(expected_clean) - && current[expected_clean.len()..] - .chars() - .next() - .is_none_or(|c| c == '.'); - } - current.starts_with(expected_clean) && current[expected_clean.len()..] .chars() @@ -680,6 +699,117 @@ pub(crate) fn version_matches(expected: &str, current: &str) -> bool { .is_none_or(|c| c == '.') } +/// True when `s` is a plain version literal — optionally `v`-prefixed, +/// then nothing but ASCII digits and dots (`20`, `20.11`, `v20.11.0`). +/// Operators, wildcards, and named aliases (`lts/*`) all return false. +fn bare_version(s: &str) -> bool { + let stripped = strip_v(s); + !stripped.is_empty() && stripped.chars().all(|c| c.is_ascii_digit() || c == '.') +} + +/// Strip a single leading `v` (`v18` → `18`) per nvm/Corepack convention. +fn strip_v(s: &str) -> &str { + s.strip_prefix('v').unwrap_or(s) +} + +/// Evaluate `expected` as a node-semver range against `current`. +/// +/// Returns `None` when the outcome could not be determined — `current` +/// isn't a version, or no `||` group both parsed and matched while at +/// least one group was unparseable — so the caller can fall back to the +/// prefix match. A parsed-and-matching group wins immediately, letting +/// `">=18 || lts/*"` succeed on the evaluable half. +fn range_matches(expected: &str, current: &str) -> Option { + let cur = parse_current_version(current)?; + let mut any_unparseable = false; + for group in expected.split("||") { + let group = group.trim(); + if group.is_empty() { + any_unparseable = true; + continue; + } + let req = normalize_range_group(group) + .and_then(|normalized| semver::VersionReq::parse(&normalized).ok()); + match req { + Some(req) if req.matches(&cur) => return Some(true), + Some(_) => {} + None => any_unparseable = true, + } + } + if any_unparseable { None } else { Some(false) } +} + +/// Rewrite one `||`-free node-semver comparator group into the +/// comma-separated grammar `semver::VersionReq::parse` accepts. +/// +/// Handles hyphen ranges (`18 - 20` → `>=18, <=20`; a partial upper +/// bound is already inclusive of its whole segment in the crate's +/// grammar), whitespace-separated AND comparators, operators detached +/// from their version (`>= 18`), and per-token `v` prefixes. Bare +/// digit-leading tokens get an `=` operator — the crate would otherwise +/// default them to caret, which is looser than node's exact-partial +/// semantics. Wildcard tokens (`*`, `x`) pass through untouched because +/// `=*` does not parse. +fn normalize_range_group(group: &str) -> Option { + let group = group.replace(',', " "); + let tokens: Vec<&str> = group.split_whitespace().collect(); + if tokens.is_empty() { + return None; + } + + if let [low, "-", high] = tokens.as_slice() { + return Some(format!(">={}, <={}", strip_v(low), strip_v(high))); + } + if tokens.contains(&"-") { + return None; + } + + let mut parts: Vec = Vec::with_capacity(tokens.len()); + let mut iter = tokens.iter(); + while let Some(token) = iter.next() { + let (op, rest) = split_operator(token); + if op.is_empty() { + let rest = strip_v(rest); + if rest.starts_with(|c: char| c.is_ascii_digit()) { + parts.push(format!("={rest}")); + } else { + parts.push(rest.to_string()); + } + } else if rest.is_empty() { + let version = iter.next()?; + parts.push(format!("{op}{}", strip_v(version))); + } else { + parts.push(format!("{op}{}", strip_v(rest))); + } + } + Some(parts.join(", ")) +} + +/// Split a leading range operator off a comparator token. Returns +/// `(op, rest)` with `op` ∈ {`>=`, `<=`, `>`, `<`, `=`, `~`, `^`, ``""``}. +fn split_operator(token: &str) -> (&str, &str) { + for op in [">=", "<=", ">", "<", "=", "~", "^"] { + if let Some(rest) = token.strip_prefix(op) { + return (op, rest); + } + } + ("", token) +} + +/// Parse `current` (a `node --version`-style string with the `v` +/// already stripped by detection) into a full [`semver::Version`], +/// padding bare `major`/`major.minor` forms to a triple. Deliberately +/// duplicates the padding in `tool::node::normalize_version` — `types` +/// must not grow a dependency on `tool`. +fn parse_current_version(current: &str) -> Option { + let padded = match current.split('.').count() { + 1 => format!("{current}.0.0"), + 2 => format!("{current}.0"), + _ => current.to_string(), + }; + semver::Version::parse(&padded).ok() +} + #[cfg(test)] mod tests { use super::version_matches; @@ -691,6 +821,121 @@ mod tests { assert!(!version_matches("20.11", "20.110.0")); } + #[test] + fn gte_range_matches_higher_versions() { + // Regression: ">=22.22.2" used to prefix-match as "=22.22.2", + // warning on 22.22.3 and 25.9.0 — both satisfy the range. + assert!(version_matches(">=22.22.2", "22.22.3")); + assert!(version_matches(">=22.22.2", "25.9.0")); + assert!(!version_matches(">=22.22.2", "22.22.1")); + } + + #[test] + fn operator_with_space_before_version() { + assert!(version_matches(">= 18", "20.0.0")); + assert!(!version_matches(">= 18", "17.9.0")); + } + + #[test] + fn partial_comparator_bounds() { + assert!(version_matches(">=18", "18.0.0")); + // node desugars ">22" to ">=23.0.0": 22.x never qualifies. + assert!(!version_matches(">22", "22.5.0")); + assert!(version_matches(">22", "23.0.0")); + assert!(version_matches("<21", "20.99.0")); + assert!(!version_matches("<21", "21.0.0")); + assert!(version_matches("<=20", "20.99.0")); + } + + #[test] + fn caret_ranges() { + // The case bare-prefix semantics must reject but caret accepts. + assert!(version_matches("^20.11", "20.12.0")); + assert!(!version_matches("^20.11", "20.10.9")); + assert!(!version_matches("^20.11", "21.0.0")); + assert!(version_matches("^0.3", "0.3.9")); + assert!(!version_matches("^0.3", "0.4.0")); + } + + #[test] + fn tilde_ranges() { + assert!(version_matches("~18.15", "18.15.7")); + assert!(!version_matches("~18.15", "18.16.0")); + assert!(version_matches("~18.15.0", "18.15.3")); + } + + #[test] + fn space_separated_and_conjunction() { + assert!(version_matches(">=18 <21", "20.5.1")); + assert!(!version_matches(">=18 <21", "21.0.0")); + assert!(!version_matches(">=18 <21", "17.0.0")); + } + + #[test] + fn or_unions() { + assert!(version_matches("18||20", "20.4.2")); + assert!(!version_matches("18||20", "19.0.0")); + assert!(version_matches(">=18 <19 || >=20", "18.5.0")); + assert!(!version_matches(">=18 <19 || >=20", "19.5.0")); + assert!(version_matches(">=18 <19 || >=20", "25.9.0")); + } + + #[test] + fn hyphen_ranges() { + assert!(version_matches("18 - 20", "19.0.0")); + // Inclusive partial upper bound: node treats "- 20" as "<21". + assert!(version_matches("18 - 20", "20.9.9")); + assert!(!version_matches("18 - 20", "21.0.0")); + assert!(!version_matches("18 - 20", "17.9.9")); + } + + #[test] + fn wildcard_ranges() { + assert!(version_matches("20.x", "20.5.1")); + assert!(!version_matches("20.x", "21.0.0")); + assert!(version_matches("20.*", "20.0.0")); + assert!(version_matches("*", "99.0.0")); + } + + #[test] + fn bare_versions_keep_prefix_semantics() { + // Regression guard for the caret trap: the semver crate would + // read a bare "20.11" as "^20.11" and accept 20.12. + assert!(!version_matches("20.11", "20.12.0")); + assert!(version_matches("20", "20.11.0")); + assert!(!version_matches("2", "20.11.0")); + assert!(version_matches("v20", "20.1.0")); + assert!(version_matches("20.11.0", "20.11.0")); + } + + #[test] + fn exact_operator_partial_equality() { + assert!(version_matches("=20.11", "20.11.5")); + assert!(!version_matches("=20.11", "20.12.0")); + } + + #[test] + fn operator_with_v_prefix() { + assert!(version_matches(">=v18", "18.0.0")); + } + + #[test] + fn unparseable_expected_falls_back_to_prefix() { + assert!(!version_matches("lts/*", "22.0.0")); + assert!(!version_matches("lts/jod", "22.0.0")); + assert!(!version_matches("", "20.0.0")); + } + + #[test] + fn unparseable_or_group_does_not_block_parsed_match() { + assert!(version_matches(">=18 || lts/*", "20.0.0")); + } + + #[test] + fn unparseable_current_falls_back_to_prefix() { + assert!(!version_matches(">=18", "not-a-version")); + } + #[test] fn detection_warning_can_be_hashed() { use std::collections::HashSet; From a4fb1d75a6a3739982bf727025cc4e31d3ed1b86 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 11 Jun 2026 02:18:08 +0200 Subject: [PATCH 2/8] feat(resolver): self-diagnosing override parse errors Unknown --pm/RUNNER_PM (and --runner/RUNNER_RUNNER) values used to be Debug-dumped verbatim: a PowerShell unquoted `$env:RUNNER_PM=deno` captures deno's multi-line REPL banner into the variable, and the error rendered it as \u{1b}-escape soup with no indication which source supplied it. Now the error names the carrying source, escapes control chars, truncates past 60 chars, and hints at the captured-command-output footgun (with the quoted spelling) when the value contains line breaks. --- CHANGELOG.md | 10 ++++ src/resolver/mod.rs | 100 ++++++++++++++++++++++++++++++++++++++ src/resolver/overrides.rs | 93 +++++++++++++++++++++++++++++++++-- 3 files changed, 199 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 305fecd..e858a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - [ ] Update the `[Unreleased]` compare link to the new tag. - [ ] Create and push a signed `vX.Y.Z` tag from `master`. +### Changed + +- 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 + 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.) + ### Fixed - Node version constraints are now evaluated with real range semantics diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index 41c85c7..ff2145b 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -460,6 +460,106 @@ mod tests { assert!(msg.contains("turbo")); } + #[test] + fn unknown_pm_env_value_names_env_source() { + let err = ResolutionOverrides::from_sources(OverrideSources { + pm: SourceValue { + cli: None, + env: Some("zoot"), + }, + ..OverrideSources::default() + }) + .expect_err("unknown PM via env should error"); + + let msg = format!("{err}"); + assert!( + msg.contains("RUNNER_PM"), + "should name the env source: {msg}" + ); + assert!(msg.contains("unknown package manager")); + } + + #[test] + fn unknown_pm_cli_value_names_cli_source() { + let err = ResolutionOverrides::from_sources(OverrideSources { + pm: SourceValue { + cli: Some("zoot"), + env: None, + }, + ..OverrideSources::default() + }) + .expect_err("unknown PM via CLI should error"); + + let msg = format!("{err}"); + assert!(msg.contains("--pm"), "should name the CLI source: {msg}"); + } + + #[test] + fn multiline_env_pm_value_is_sanitized_and_hinted() { + // The PowerShell unquoted-assignment footgun: `$env:RUNNER_PM=deno` + // executes deno and captures its REPL banner (ANSI codes included) + // into the variable. + let banner = "Deno 2.8.2 exit using ctrl+d\n\u{1b}[33mREPL is running\u{1b}[0m"; + let err = ResolutionOverrides::from_sources(OverrideSources { + pm: SourceValue { + cli: None, + env: Some(banner), + }, + ..OverrideSources::default() + }) + .expect_err("captured banner should error"); + + let msg = format!("{err}"); + assert!(!msg.contains('\u{1b}'), "raw ESC byte must not leak: {msg}"); + assert!( + msg.contains("captured command output"), + "should hint at the footgun: {msg}" + ); + assert!( + msg.contains("$env:RUNNER_PM='pnpm'"), + "should show the quoted PowerShell spelling: {msg}" + ); + } + + #[test] + fn oversized_pm_value_is_truncated() { + let huge = "z".repeat(500); + let err = ResolutionOverrides::from_sources(OverrideSources { + pm: SourceValue { + cli: None, + env: Some(&huge), + }, + ..OverrideSources::default() + }) + .expect_err("oversized garbage should error"); + + let msg = format!("{err}"); + assert!(msg.contains('…'), "long values should be truncated: {msg}"); + assert!( + !msg.contains(&"z".repeat(100)), + "the full 500-char value must not be rendered: {msg}" + ); + } + + #[test] + fn unknown_runner_env_value_names_env_source() { + let err = ResolutionOverrides::from_sources(OverrideSources { + runner: SourceValue { + cli: None, + env: Some("zoot"), + }, + ..OverrideSources::default() + }) + .expect_err("unknown runner via env should error"); + + let msg = format!("{err}"); + assert!( + msg.contains("RUNNER_RUNNER"), + "should name the env source: {msg}" + ); + assert!(msg.contains("unknown task runner")); + } + #[test] fn pm_label_that_names_a_runner_suggests_runner_flag() { let err = ResolutionOverrides::from_sources(OverrideSources { diff --git a/src/resolver/overrides.rs b/src/resolver/overrides.rs index cd5c020..2455a07 100644 --- a/src/resolver/overrides.rs +++ b/src/resolver/overrides.rs @@ -106,12 +106,14 @@ impl ResolutionOverrides { let pm = parse_override( sources.pm.cli, sources.pm.env, + &PM_SOURCE_NAMES, parse_pm_label, |pm, origin| PmOverride { pm, origin }, )?; let runner = parse_override( sources.runner.cli, sources.runner.env, + &RUNNER_SOURCE_NAMES, parse_runner_label, |runner, origin| RunnerOverride { runner, origin }, )?; @@ -197,7 +199,8 @@ fn parse_pm_label(raw: &str) -> Result { )); } Err(anyhow!( - "unknown package manager {raw:?}; expected one of {}", + "unknown package manager \"{}\"; expected one of {}", + sanitize_raw_label(raw), join_labels( PackageManager::all() .iter() @@ -219,20 +222,100 @@ fn parse_runner_label(raw: &str) -> Result { )); } Err(anyhow!( - "unknown task runner {raw:?}; expected one of {}", + "unknown task runner \"{}\"; expected one of {}", + sanitize_raw_label(raw), join_labels(TaskRunner::all().iter().copied().map(TaskRunner::label)), )) } +/// Maximum characters of a raw override value rendered in an error. +const MAX_RAW_DISPLAY: usize = 60; + +/// Render an untrusted override value safely for a one-line error: +/// control characters (ANSI escapes, newlines) are escaped via +/// [`char::escape_debug`], then the escaped string is truncated to +/// [`MAX_RAW_DISPLAY`] characters with an ellipsis. Values come straight +/// from the environment and can be arbitrary captured command output — +/// an unquoted PowerShell `$env:RUNNER_PM=deno` assigns deno's entire +/// REPL banner, ANSI codes and all. +fn sanitize_raw_label(raw: &str) -> String { + let escaped: String = raw.chars().flat_map(char::escape_debug).collect(); + let mut chars = escaped.chars(); + let truncated: String = chars.by_ref().take(MAX_RAW_DISPLAY).collect(); + if chars.next().is_some() { + format!("{truncated}…") + } else { + truncated + } +} + +/// Source names for the cross-ecosystem PM override. +const PM_SOURCE_NAMES: SourceNames = SourceNames { + cli: "--pm", + env: "RUNNER_PM", + example: "pnpm", +}; + +/// Source names for the task-runner override. +const RUNNER_SOURCE_NAMES: SourceNames = SourceNames { + cli: "--runner", + env: "RUNNER_RUNNER", + example: "just", +}; + +/// The user-facing names of one override's sources, used to attribute +/// parse errors to the flag or variable that carried the bad value. +struct SourceNames { + /// CLI flag, e.g. `--pm`. + cli: &'static str, + /// Environment variable, e.g. `RUNNER_PM`. + env: &'static str, + /// A valid example value, e.g. `pnpm`. + example: &'static str, +} + +impl SourceNames { + /// Prefix `err` with the source that supplied `raw`. When the value + /// contains line breaks it is almost certainly captured command + /// output rather than a name the user typed (the PowerShell + /// unquoted-assignment footgun), so append a hint showing the + /// correct spelling for that source. + fn decorate(&self, err: &anyhow::Error, raw: &str, origin: &OverrideOrigin) -> anyhow::Error { + let from_env = matches!(origin, OverrideOrigin::EnvVar); + let source = if from_env { self.env } else { self.cli }; + let hint = if raw.contains('\n') || raw.contains('\r') { + let example = if from_env { + format!( + "$env:{}='{}' (quote the value in PowerShell)", + self.env, self.example + ) + } else { + format!("{} {}", self.cli, self.example) + }; + format!( + "\n hint: the value contains line breaks and looks like captured command \ + output; pass a plain name instead, e.g. {example}" + ) + } else { + String::new() + }; + anyhow!("{source}: {err}{hint}") + } +} + /// Generic CLI-then-env override parser. CLI wins; whitespace is /// trimmed from both sources before parsing so `RUNNER_PM=" pnpm "` /// works the same as `RUNNER_PM=pnpm`. Empty/whitespace-only values /// are treated as unset so a user can clear an inherited variable with /// `RUNNER_PM= runner …`. Matches the whitespace handling used by /// [`super::policies::is_env_truthy`] for boolean env flags. +/// +/// Parse failures are attributed to the source that carried the value +/// (`names.cli` or `names.env`) via [`SourceNames::decorate`]. fn parse_override( cli: Option<&str>, env: Option<&str>, + names: &SourceNames, parse: V, build: B, ) -> Result> @@ -241,11 +324,13 @@ where B: Fn(P, OverrideOrigin) -> T, { if let Some(raw) = cli.map(str::trim).filter(|s| !s.is_empty()) { - let parsed = parse(raw)?; + let parsed = + parse(raw).map_err(|err| names.decorate(&err, raw, &OverrideOrigin::CliFlag))?; return Ok(Some(build(parsed, OverrideOrigin::CliFlag))); } if let Some(raw) = env.map(str::trim).filter(|s| !s.is_empty()) { - let parsed = parse(raw)?; + let parsed = + parse(raw).map_err(|err| names.decorate(&err, raw, &OverrideOrigin::EnvVar))?; return Ok(Some(build(parsed, OverrideOrigin::EnvVar))); } Ok(None) From d53bb86f8423c4827b12358ce127c00518e90b66 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 11 Jun 2026 02:25:35 +0200 Subject: [PATCH 3/8] feat(install): honor --pm/RUNNER_PM override for the install set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `install_pms` received the overrides but only used them for the GHA log group, so `RUNNER_PM=bun runner install` still ran every detected PM — in a bun+deno repo, `deno install` always ran and wrote a deno.lock nobody asked for. The override now selects the install set: a detected PM installs alone; an undetected one refuses with the new `ResolveError::PmOverrideNotDetected` (exit 2), naming the carrying source and the detected set. The chain path (`runner install `) inherits the behavior. The "via " provenance fragment moved to `OverrideOrigin::describe_pm_source` so `--explain` and the install error share one wording. --- CHANGELOG.md | 7 ++ src/cmd/install.rs | 168 +++++++++++++++++++++++++++++++++++++----- src/lib.rs | 45 +++++++++++ src/resolver/error.rs | 60 +++++++++++++++ src/resolver/mod.rs | 5 ++ src/resolver/types.rs | 25 +++++-- 6 files changed, 283 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e858a03..e135263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic ### Changed +- `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 + `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 + `[pm].node`/`[pm].python` continue to scope script dispatch only. - 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 diff --git a/src/cmd/install.rs b/src/cmd/install.rs index 6291f66..ca43e05 100644 --- a/src/cmd/install.rs +++ b/src/cmd/install.rs @@ -9,7 +9,7 @@ use anyhow::{Result, bail}; use colored::Colorize; use crate::chain::mux::{LineSink, StdioSink, prefix_width, render_prefix, spawn_readers}; -use crate::resolver::ResolutionOverrides; +use crate::resolver::{ResolutionOverrides, ResolveError}; use crate::tool; use crate::types::{PackageManager, ProjectContext, TaskRunner, version_matches}; @@ -47,9 +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 + // no-PM bail above. + let pms = select_install_pms(ctx, overrides)?; + // Collapse the whole install (single- or multi-PM) under one - // `runner: install` GitHub Actions group when enabled. Opened after the - // no-PM bail so a failed detection doesn't emit an empty group. + // `runner: install` GitHub Actions group when enabled. let _group = super::task_group(overrides, "install"); if let (Some(nv), Some(cur)) = (&ctx.node_version, &ctx.current_node) @@ -65,20 +69,48 @@ pub(crate) fn install_pms( suggest_version_switch(ctx); } - if ctx.package_managers.len() == 1 { - let pm = ctx.package_managers[0]; - eprintln!("{} {}", "installing with".dimmed(), pm.label().bold()); - let mut cmd = build_install_command(ctx, pm, frozen); - super::configure_command(&mut cmd, &ctx.root); - let status = cmd.status()?; - return Ok(if status.success() { - 0 - } else { - super::exit_code(status) - }); + if let [pm] = pms.as_slice() { + return install_single(ctx, *pm, frozen); } - run_installs_parallel(ctx, frozen) + run_installs_parallel(ctx, &pms, frozen) +} + +/// Which PMs this invocation installs with: the cross-ecosystem +/// `--pm`/`RUNNER_PM` override when present (which must name a detected +/// PM), else every detected PM. +/// +/// `pm_by_ecosystem` (runner.toml `[pm].node`/`[pm].python`) is +/// deliberately NOT consulted: it scopes *script dispatch* to an +/// ecosystem, and filtering a polyglot install by it is ill-defined — +/// `[pm].node = "yarn"` saying anything about whether `cargo fetch` +/// runs would be surprising in both directions. +fn select_install_pms( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, +) -> Result, ResolveError> { + match &overrides.pm { + Some(o) if ctx.package_managers.contains(&o.pm) => Ok(vec![o.pm]), + Some(o) => Err(ResolveError::PmOverrideNotDetected { + pm: o.pm, + origin: o.origin.clone(), + detected: ctx.package_managers.clone(), + }), + None => Ok(ctx.package_managers.clone()), + } +} + +/// Run a single PM's install in the foreground, inheriting stdio. +fn install_single(ctx: &ProjectContext, pm: PackageManager, frozen: bool) -> Result { + eprintln!("{} {}", "installing with".dimmed(), pm.label().bold()); + let mut cmd = build_install_command(ctx, pm, frozen); + super::configure_command(&mut cmd, &ctx.root); + let status = cmd.status()?; + Ok(if status.success() { + 0 + } else { + super::exit_code(status) + }) } /// Run every detected package manager's install in parallel, multiplexing @@ -91,19 +123,23 @@ pub(crate) fn install_pms( /// 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." -fn run_installs_parallel(ctx: &ProjectContext, frozen: bool) -> Result { +fn run_installs_parallel( + ctx: &ProjectContext, + pms: &[PackageManager], + frozen: bool, +) -> Result { use std::process::Child; - let names: Vec<&str> = ctx.package_managers.iter().map(|pm| pm.label()).collect(); + let names: Vec<&str> = 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 mut children: Vec<(PackageManager, Child)> = Vec::with_capacity(ctx.package_managers.len()); + let mut children: Vec<(PackageManager, Child)> = Vec::with_capacity(pms.len()); let mut reader_handles = Vec::new(); let spawn_outcome: Result<()> = (|| { - for pm in &ctx.package_managers { + for pm in pms { eprintln!("{} {}", "installing with".dimmed(), pm.label().bold()); let mut cmd = build_install_command(ctx, *pm, frozen); super::configure_command(&mut cmd, &ctx.root); @@ -204,3 +240,97 @@ fn suggest_version_switch(ctx: &ProjectContext) { }; eprintln!(" {} {}", "hint:".dimmed(), hint); } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::path::PathBuf; + + use super::select_install_pms; + use crate::resolver::{OverrideOrigin, PmOverride, ResolutionOverrides, ResolveError}; + use crate::types::{Ecosystem, PackageManager, ProjectContext}; + + fn context(pms: Vec) -> ProjectContext { + ProjectContext { + root: PathBuf::from("/tmp/test"), + package_managers: pms, + task_runners: Vec::new(), + tasks: Vec::new(), + node_version: None, + current_node: None, + is_monorepo: false, + warnings: Vec::new(), + } + } + + fn override_pm(pm: PackageManager, origin: OverrideOrigin) -> ResolutionOverrides { + ResolutionOverrides { + pm: Some(PmOverride { pm, origin }), + ..Default::default() + } + } + + #[test] + fn no_override_installs_with_every_detected_pm() { + let ctx = context(vec![PackageManager::Bun, PackageManager::Deno]); + let pms = select_install_pms(&ctx, &ResolutionOverrides::default()) + .expect("default selection should succeed"); + assert_eq!(pms, vec![PackageManager::Bun, PackageManager::Deno]); + } + + #[test] + fn detected_override_installs_with_it_alone() { + // 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); + let pms = select_install_pms(&ctx, &overrides).expect("detected override should filter"); + assert_eq!(pms, vec![PackageManager::Bun]); + } + + #[test] + fn undetected_override_errors_with_origin_and_detected_list() { + let ctx = context(vec![PackageManager::Cargo]); + let overrides = override_pm(PackageManager::Npm, OverrideOrigin::EnvVar); + let err = select_install_pms(&ctx, &overrides).expect_err("undetected override must error"); + + assert!(matches!(err, ResolveError::PmOverrideNotDetected { .. })); + let msg = format!("{err}"); + assert!(msg.contains("RUNNER_PM"), "should name the source: {msg}"); + assert!(msg.contains("cargo"), "should list detected PMs: {msg}"); + } + + #[test] + fn undetected_cli_override_names_the_flag() { + let ctx = context(vec![PackageManager::Cargo]); + let overrides = override_pm(PackageManager::Npm, OverrideOrigin::CliFlag); + let err = select_install_pms(&ctx, &overrides).expect_err("undetected override must error"); + + let msg = format!("{err}"); + assert!(msg.contains("--pm"), "should name the flag: {msg}"); + } + + #[test] + fn ecosystem_config_override_does_not_filter_installs() { + // Pins the documented non-goal: `[pm].node` in runner.toml scopes + // script dispatch, not the install set. + let ctx = context(vec![PackageManager::Bun, PackageManager::Cargo]); + let mut pm_by_ecosystem = HashMap::new(); + pm_by_ecosystem.insert( + Ecosystem::Node, + PmOverride { + pm: PackageManager::Pnpm, + origin: OverrideOrigin::ConfigFile { + path: PathBuf::from("/tmp/test/runner.toml"), + }, + }, + ); + let overrides = ResolutionOverrides { + pm_by_ecosystem, + ..Default::default() + }; + + let pms = select_install_pms(&ctx, &overrides).expect("config must not filter installs"); + assert_eq!(pms, vec![PackageManager::Bun, PackageManager::Cargo]); + } +} diff --git a/src/lib.rs b/src/lib.rs index 0c98394..0ea8c63 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1117,6 +1117,51 @@ mod tests { assert!(format!("{err}").contains("unknown package manager")); } + #[test] + fn install_with_undetected_pm_override_exits_2() { + // A cargo-only project with `--pm npm`: the override can't be + // honored, so install must refuse with a ResolveError (exit 2) + // before spawning anything. + let dir = TempDir::new("runner-install-undetected-pm"); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n", + ) + .expect("write Cargo.toml"); + + let err = run_in_dir(["runner", "--pm", "npm", "install"], dir.path()) + .expect_err("undetected --pm should refuse the install"); + + assert_eq!( + exit_code_for_error(&err), + 2, + "ResolveError must map to exit 2" + ); + let msg = format!("{err}"); + assert!(msg.contains("--pm"), "should name the source: {msg}"); + assert!(msg.contains("cargo"), "should list detected PMs: {msg}"); + } + + #[test] + fn install_chain_with_undetected_pm_override_exits_2() { + // Same refusal through the chain path (`runner install `). + let dir = TempDir::new("runner-install-chain-undetected-pm"); + fs::write( + dir.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n", + ) + .expect("write Cargo.toml"); + + let err = run_in_dir(["runner", "--pm", "npm", "install", "build"], dir.path()) + .expect_err("undetected --pm should refuse the install chain"); + + assert_eq!( + exit_code_for_error(&err), + 2, + "ResolveError must map to exit 2" + ); + } + #[test] fn schema_version_rejects_invalid_for_non_json_commands() { let dir = TempDir::new("runner-schema-invalid-completions"); diff --git a/src/resolver/error.rs b/src/resolver/error.rs index bd36023..035283c 100644 --- a/src/resolver/error.rs +++ b/src/resolver/error.rs @@ -71,6 +71,18 @@ pub(crate) enum ResolveError { /// so the `Display` impl produces a clean one-line message. reason: &'static str, }, + /// A `--pm` / `RUNNER_PM` override names a PM that detection did not + /// find in the project, so `runner install` cannot honor it. Erroring + /// (rather than silently installing with the detected set) keeps the + /// override a contract: what the user pinned is what runs. + PmOverrideNotDetected { + /// The PM the override named. + pm: PackageManager, + /// Where the override came from (flag, env var, config file). + origin: super::types::OverrideOrigin, + /// What detection actually found, for the error message. + detected: Vec, + }, /// Both `keep_going` and `kill_on_fail` were set to true at the same /// source (or once layered across CLI/env/config). The chain executor /// can't honour both, so fail loudly before dispatching anything. @@ -137,6 +149,29 @@ impl fmt::Display for ResolveError { Self::InvalidOverride { value, reason } => { write!(f, "invalid override value {value:?}: {reason}") } + Self::PmOverrideNotDetected { + pm, + origin, + detected, + } => { + let detected = if detected.is_empty() { + "none".to_string() + } else { + detected + .iter() + .map(|pm| pm.label()) + .collect::>() + .join(", ") + }; + write!( + f, + "cannot install with {} {}: not a detected package manager in this project \ + (detected: {detected}). Install {} or drop the override.", + pm.label(), + origin.describe_pm_source(), + pm.label(), + ) + } Self::ConflictingFailurePolicy { source } => write!( f, "`keep_going` and `kill_on_fail` are mutually exclusive but both were set ({source}). \ @@ -153,6 +188,31 @@ impl std::error::Error for ResolveError {} mod tests { use super::*; + #[test] + fn pm_override_not_detected_display_names_source_and_detected() { + let err = ResolveError::PmOverrideNotDetected { + pm: PackageManager::Pnpm, + origin: super::super::types::OverrideOrigin::EnvVar, + detected: vec![PackageManager::Npm, PackageManager::Cargo], + }; + let msg = format!("{err}"); + assert!(msg.contains("pnpm"), "msg: {msg}"); + assert!(msg.contains("RUNNER_PM"), "msg: {msg}"); + assert!(msg.contains("npm, cargo"), "msg: {msg}"); + } + + #[test] + fn pm_override_not_detected_display_handles_empty_detected() { + let err = ResolveError::PmOverrideNotDetected { + pm: PackageManager::Pnpm, + origin: super::super::types::OverrideOrigin::CliFlag, + detected: Vec::new(), + }; + let msg = format!("{err}"); + assert!(msg.contains("detected: none"), "msg: {msg}"); + assert!(msg.contains("--pm"), "msg: {msg}"); + } + #[test] fn conflicting_failure_policy_display_includes_source() { let err = ResolveError::ConflictingFailurePolicy { source: "env vars" }; diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index ff2145b..edfce48 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -42,6 +42,11 @@ pub(crate) use error::{DevEnginesFailReason, ResolveError}; /// Lets `cmd::doctor` exercise the same PATH walk the resolver uses without /// owning the env-reading logic. pub(crate) use probe::probe_in as probe_path_for_doctor; +/// Re-exported for unit tests that need to construct override state +/// directly (e.g. `cmd::install::tests`); production code receives +/// overrides fully built by [`ResolutionOverrides::from_cli_and_env`]. +#[cfg(test)] +pub(crate) use types::PmOverride; pub(crate) use types::{ DiagnosticFlags, FallbackPolicy, MismatchPolicy, OverrideOrigin, ResolutionOverrides, ResolvedPm, Resolver, diff --git a/src/resolver/types.rs b/src/resolver/types.rs index 9e42de1..81d47b6 100644 --- a/src/resolver/types.rs +++ b/src/resolver/types.rs @@ -164,6 +164,21 @@ pub(crate) enum OverrideOrigin { }, } +impl OverrideOrigin { + /// Render the "via …" provenance fragment for a PM override from + /// this origin: `via --pm (CLI override)`, `via RUNNER_PM + /// (environment)`, or `via runner.toml at `. Shared by + /// [`ResolvedPm::describe`] and install's override errors so the + /// attribution wording stays identical everywhere. + pub(crate) fn describe_pm_source(&self) -> String { + match self { + Self::CliFlag => "via --pm (CLI override)".to_string(), + Self::EnvVar => "via RUNNER_PM (environment)".to_string(), + Self::ConfigFile { path } => format!("via runner.toml at {}", path.display()), + } + } +} + /// A package-manager decision plus the chain step that produced it. #[derive(Debug, Clone)] pub(crate) struct ResolvedPm { @@ -291,14 +306,8 @@ impl ResolvedPm { /// decision. Used by `--explain` to attribute the PM choice. pub(crate) fn describe(&self) -> String { match &self.via { - ResolutionStep::Override(OverrideOrigin::CliFlag) => { - format!("{} via --pm (CLI override)", self.pm.label()) - } - ResolutionStep::Override(OverrideOrigin::EnvVar) => { - format!("{} via RUNNER_PM (environment)", self.pm.label()) - } - ResolutionStep::Override(OverrideOrigin::ConfigFile { path }) => { - format!("{} via runner.toml at {}", self.pm.label(), path.display()) + ResolutionStep::Override(origin) => { + format!("{} {}", self.pm.label(), origin.describe_pm_source()) } ResolutionStep::ManifestPackageManager => { format!("{} via package.json \"packageManager\"", self.pm.label()) From 2f319f0f59735b9222d3608cb369cf1e2523ce05 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 11 Jun 2026 02:36:06 +0200 Subject: [PATCH 4/8] feat(doctor): survive unparseable RUNNER_* env overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An invalid env override (e.g. a REPL banner captured by PowerShell unquoted assignment) killed every command at override parsing — before dispatch — including `runner doctor`, the one command whose job is to diagnose a broken environment. Dispatch now retries doctor through a lenient constructor that pre-validates env-sourced values (RUNNER_PM, RUNNER_RUNNER, RUNNER_FALLBACK, RUNNER_ON_MISMATCH), blanking invalid ones into `DetectionWarning::InvalidEnvOverride` rendered on the report. Strict behavior is untouched for all other commands and for CLI flag values; CLI-shadowed env garbage stays invisible, mirroring strict precedence. Env reads are deduped into EnvSnapshot so the two constructors cannot drift. --- CHANGELOG.md | 6 + src/lib.rs | 56 ++++++++- src/resolver/mod.rs | 112 ++++++++++++++++- src/resolver/overrides.rs | 247 +++++++++++++++++++++++++++++++------- src/resolver/policies.rs | 2 +- src/types.rs | 18 +++ tests/doctor_env.rs | 137 +++++++++++++++++++++ 7 files changed, 528 insertions(+), 50 deletions(-) create mode 100644 tests/doctor_env.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e135263..cc5cee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,12 @@ 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 + 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 + `--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 `>=22.22.2` as `=22.22.2`. Operators (`>=`, `>`, `<=`, `<`, `=`), diff --git a/src/lib.rs b/src/lib.rs index 0ea8c63..c8ae401 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -615,10 +615,62 @@ fn build_overrides( ) } +/// Lenient sibling of [`build_overrides`] used when strict parsing +/// failed and the command is `doctor`: invalid env-sourced override +/// values degrade to [`types::DetectionWarning`]s instead of killing +/// the one command whose job is to report a broken environment. +fn build_overrides_lenient( + cli: &cli::Cli, + loaded_config: Option<&config::LoadedConfig>, +) -> Result<(resolver::ResolutionOverrides, Vec)> { + let (cli_keep_going, cli_kill_on_fail) = match cli.command.as_ref() { + Some(cli::Command::Run { failure, .. } | cli::Command::Install { failure, .. }) => { + (failure.keep_going, failure.kill_on_fail) + } + _ => (false, false), + }; + resolver::ResolutionOverrides::from_cli_and_env_lenient( + cli.global.pm_override.as_deref(), + cli.global.runner_override.as_deref(), + cli.global.fallback.as_deref(), + cli.global.on_mismatch.as_deref(), + resolver::DiagnosticFlags { + no_warnings: cli.global.no_warnings, + explain: cli.global.explain, + }, + cli::ChainFailureFlags { + keep_going: cli_keep_going, + kill_on_fail: cli_kill_on_fail, + }, + loaded_config, + ) +} + +/// 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 +/// degrades to warnings appended to `ctx`, while CLI flag garbage +/// re-raises from the lenient pass and stays fatal. +fn dispatch_overrides( + cli: &cli::Cli, + loaded_config: Option<&config::LoadedConfig>, + ctx: &mut types::ProjectContext, +) -> Result { + match build_overrides(cli, loaded_config) { + Ok(overrides) => Ok(overrides), + Err(_) if matches!(cli.command, Some(cli::Command::Doctor { .. })) => { + let (overrides, env_warnings) = build_overrides_lenient(cli, loaded_config)?; + ctx.warnings.extend(env_warnings); + Ok(overrides) + } + Err(e) => Err(e), + } +} + fn dispatch(cli: cli::Cli, dir: &Path) -> Result { - let ctx = detect::detect(dir); + let mut ctx = detect::detect(dir); let loaded_config = config::load(dir)?; - let overrides = build_overrides(&cli, loaded_config.as_ref())?; + let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?; match cli.command { // A project task named `info` always shadows the deprecated diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index edfce48..88ed8a2 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -71,7 +71,7 @@ mod tests { }; use super::{FallbackPolicy, OverrideOrigin, ResolutionOverrides, ResolveError, Resolver}; use crate::config::{LoadedConfig, PmSection, RunnerConfig}; - use crate::types::{Ecosystem, PackageManager, ProjectContext, TaskRunner}; + use crate::types::{DetectionWarning, Ecosystem, PackageManager, ProjectContext, TaskRunner}; fn context(package_managers: Vec) -> ProjectContext { ProjectContext { @@ -565,6 +565,116 @@ mod tests { assert!(msg.contains("unknown task runner")); } + #[test] + fn lenient_env_pm_garbage_degrades_to_warning() { + let (overrides, warnings) = ResolutionOverrides::from_sources_lenient(OverrideSources { + pm: SourceValue { + cli: None, + env: Some("Deno 2.8.2 exit using ctrl+d\n\u{1b}[33mbanner"), + }, + ..OverrideSources::default() + }) + .expect("lenient pass must absorb env garbage"); + + assert!(overrides.pm.is_none(), "garbage override must be blanked"); + assert_eq!(warnings.len(), 1); + match &warnings[0] { + DetectionWarning::InvalidEnvOverride { var, raw, .. } => { + assert_eq!(*var, "RUNNER_PM"); + assert!(!raw.contains('\u{1b}'), "raw must be sanitized: {raw}"); + } + other => panic!("expected InvalidEnvOverride, got {other:?}"), + } + let detail = warnings[0].detail(); + assert!(detail.contains("ignored"), "detail: {detail}"); + } + + #[test] + fn lenient_cli_garbage_still_errors() { + ResolutionOverrides::from_sources_lenient(OverrideSources { + pm: SourceValue { + cli: Some("zoot"), + env: None, + }, + ..OverrideSources::default() + }) + .expect_err("explicit CLI garbage must stay fatal even leniently"); + } + + #[test] + fn lenient_valid_env_produces_no_warnings() { + let (overrides, warnings) = ResolutionOverrides::from_sources_lenient(OverrideSources { + pm: SourceValue { + cli: None, + env: Some("bun"), + }, + ..OverrideSources::default() + }) + .expect("valid env value should parse"); + + assert!(warnings.is_empty()); + assert_eq!( + overrides.pm.expect("pm should be set").pm, + PackageManager::Bun + ); + } + + #[test] + fn lenient_cli_value_shadows_env_garbage() { + // Strict precedence never parses a CLI-shadowed env value, so the + // lenient pass must not warn about it either. + let (overrides, warnings) = ResolutionOverrides::from_sources_lenient(OverrideSources { + pm: SourceValue { + cli: Some("yarn"), + env: Some("complete garbage\nwith newlines"), + }, + ..OverrideSources::default() + }) + .expect("CLI value should shadow env garbage"); + + assert!( + warnings.is_empty(), + "shadowed env must not warn: {warnings:?}" + ); + assert_eq!( + overrides.pm.expect("pm should be set").pm, + PackageManager::Yarn + ); + } + + #[test] + fn lenient_covers_runner_fallback_and_mismatch_vars() { + let (overrides, warnings) = ResolutionOverrides::from_sources_lenient(OverrideSources { + runner: SourceValue { + cli: None, + env: Some("bogus-runner"), + }, + fallback: SourceValue { + cli: None, + env: Some("bogus-policy"), + }, + on_mismatch: SourceValue { + cli: None, + env: Some("bogus-mismatch"), + }, + ..OverrideSources::default() + }) + .expect("lenient pass must absorb all env-sourced garbage"); + + assert!(overrides.runner.is_none()); + let vars: Vec<&str> = warnings + .iter() + .map(|w| match w { + DetectionWarning::InvalidEnvOverride { var, .. } => *var, + other => panic!("expected InvalidEnvOverride, got {other:?}"), + }) + .collect(); + assert_eq!( + vars, + vec!["RUNNER_RUNNER", "RUNNER_FALLBACK", "RUNNER_ON_MISMATCH"] + ); + } + #[test] fn pm_label_that_names_a_runner_suggests_runner_flag() { let err = ResolutionOverrides::from_sources(OverrideSources { diff --git a/src/resolver/overrides.rs b/src/resolver/overrides.rs index 2455a07..cb45552 100644 --- a/src/resolver/overrides.rs +++ b/src/resolver/overrides.rs @@ -8,15 +8,15 @@ use anyhow::{Result, anyhow}; use super::join_labels; use super::policies::{ - is_env_truthy, parse_prefer_runners, resolve_failure_policy, resolve_fallback_policy, - resolve_mismatch_policy, + is_env_truthy, parse_fallback_label, parse_mismatch_label, parse_prefer_runners, + resolve_failure_policy, resolve_fallback_policy, resolve_mismatch_policy, }; use super::types::{ DiagnosticFlags, ExplainSource, OverrideOrigin, OverrideSources, PmOverride, ResolutionOverrides, RunnerOverride, SourceValue, }; use crate::config::{LoadedConfig, parse_node_pm, parse_python_pm}; -use crate::types::{Ecosystem, PackageManager, TaskRunner}; +use crate::types::{DetectionWarning, Ecosystem, PackageManager, TaskRunner}; impl ResolutionOverrides { /// Assemble overrides from CLI flag values (already parsed by clap), @@ -42,49 +42,87 @@ impl ResolutionOverrides { failure: crate::cli::ChainFailureFlags, config: Option<&LoadedConfig>, ) -> Result { - let env_pm = std::env::var("RUNNER_PM").ok(); - let env_runner = std::env::var("RUNNER_RUNNER").ok(); - let env_fallback = std::env::var("RUNNER_FALLBACK").ok(); - let env_on_mismatch = std::env::var("RUNNER_ON_MISMATCH").ok(); - let env_no_warnings = std::env::var("RUNNER_NO_WARNINGS").ok(); - let env_explain = std::env::var("RUNNER_EXPLAIN").ok(); - let env_keep_going = std::env::var("RUNNER_KEEP_GOING").ok(); - let env_kill_on_fail = std::env::var("RUNNER_KILL_ON_FAIL").ok(); - Self::from_sources(OverrideSources { - pm: SourceValue { - cli: cli_pm, - env: env_pm.as_deref(), - }, - runner: SourceValue { - cli: cli_runner, - env: env_runner.as_deref(), - }, - fallback: SourceValue { - cli: cli_fallback, - env: env_fallback.as_deref(), - }, - on_mismatch: SourceValue { - cli: cli_on_mismatch, - env: env_on_mismatch.as_deref(), - }, - no_warnings: ExplainSource { - cli: diagnostics.no_warnings, - env: env_no_warnings.as_deref(), - }, - explain: ExplainSource { - cli: diagnostics.explain, - env: env_explain.as_deref(), - }, - keep_going: ExplainSource { - cli: failure.keep_going, - env: env_keep_going.as_deref(), - }, - kill_on_fail: ExplainSource { - cli: failure.kill_on_fail, - env: env_kill_on_fail.as_deref(), - }, - config, - }) + let env = EnvSnapshot::capture(); + let cli = CliSides { + pm: cli_pm, + runner: cli_runner, + fallback: cli_fallback, + on_mismatch: cli_on_mismatch, + diagnostics, + failure, + }; + Self::from_sources(env.sources(cli, config)) + } + + /// Lenient sibling of [`Self::from_cli_and_env`] for commands that + /// 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 + /// [`DetectionWarning::InvalidEnvOverride`]; CLI flag values stay + /// strict (an explicit flag is an explicit failure). + /// + /// # Errors + /// + /// Returns an error for everything the strict path rejects except + /// unparseable env override values: bad CLI values, invalid + /// `runner.toml` fields, conflicting failure-policy toggles. + pub(crate) fn from_cli_and_env_lenient( + cli_pm: Option<&str>, + cli_runner: Option<&str>, + cli_fallback: Option<&str>, + cli_on_mismatch: Option<&str>, + diagnostics: DiagnosticFlags, + failure: crate::cli::ChainFailureFlags, + config: Option<&LoadedConfig>, + ) -> Result<(Self, Vec)> { + let env = EnvSnapshot::capture(); + let cli = CliSides { + pm: cli_pm, + runner: cli_runner, + fallback: cli_fallback, + on_mismatch: cli_on_mismatch, + diagnostics, + failure, + }; + Self::from_sources_lenient(env.sources(cli, config)) + } + + /// Pure-function counterpart of [`Self::from_cli_and_env_lenient`]: + /// 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 + /// shadowed by a CLI value is never parsed by the strict path, so + /// it is not validated (or warned about) here either. + /// + /// # Errors + /// + /// Same as [`Self::from_cli_and_env_lenient`]. + pub(crate) fn from_sources_lenient( + mut sources: OverrideSources<'_>, + ) -> Result<(Self, Vec)> { + let mut warnings = Vec::new(); + lenient_env_field(&mut sources.pm, "RUNNER_PM", &mut warnings, |raw| { + parse_pm_label(raw).map(drop) + }); + lenient_env_field(&mut sources.runner, "RUNNER_RUNNER", &mut warnings, |raw| { + parse_runner_label(raw).map(drop) + }); + lenient_env_field( + &mut sources.fallback, + "RUNNER_FALLBACK", + &mut warnings, + |raw| parse_fallback_label(raw).map(drop), + ); + lenient_env_field( + &mut sources.on_mismatch, + "RUNNER_ON_MISMATCH", + &mut warnings, + |raw| parse_mismatch_label(raw).map(drop), + ); + let overrides = Self::from_sources(sources)?; + Ok((overrides, warnings)) } /// Pure-function constructor that consumes a fully-populated @@ -249,6 +287,123 @@ fn sanitize_raw_label(raw: &str) -> String { } } +/// The CLI-flag half of an override assembly, bundled so +/// [`EnvSnapshot::sources`] pairs one CLI side with one env snapshot +/// instead of threading seven loose parameters. +#[derive(Clone, Copy)] +struct CliSides<'a> { + pm: Option<&'a str>, + runner: Option<&'a str>, + fallback: Option<&'a str>, + on_mismatch: Option<&'a str>, + diagnostics: DiagnosticFlags, + failure: crate::cli::ChainFailureFlags, +} + +/// Captured `RUNNER_*` environment, separated from [`OverrideSources`] +/// assembly so the strict and lenient constructors share one read path +/// and can never drift on which variables they consult. +struct EnvSnapshot { + pm: Option, + runner: Option, + fallback: Option, + on_mismatch: Option, + no_warnings: Option, + explain: Option, + keep_going: Option, + kill_on_fail: Option, +} + +impl EnvSnapshot { + /// Read every `RUNNER_*` override variable from the process + /// environment. + fn capture() -> Self { + Self { + pm: std::env::var("RUNNER_PM").ok(), + runner: std::env::var("RUNNER_RUNNER").ok(), + fallback: std::env::var("RUNNER_FALLBACK").ok(), + on_mismatch: std::env::var("RUNNER_ON_MISMATCH").ok(), + no_warnings: std::env::var("RUNNER_NO_WARNINGS").ok(), + explain: std::env::var("RUNNER_EXPLAIN").ok(), + keep_going: std::env::var("RUNNER_KEEP_GOING").ok(), + kill_on_fail: std::env::var("RUNNER_KILL_ON_FAIL").ok(), + } + } + + /// Pair the captured environment with the CLI flag values into the + /// [`OverrideSources`] consumed by the constructors. + fn sources<'a>( + &'a self, + cli: CliSides<'a>, + config: Option<&'a LoadedConfig>, + ) -> OverrideSources<'a> { + OverrideSources { + pm: SourceValue { + cli: cli.pm, + env: self.pm.as_deref(), + }, + runner: SourceValue { + cli: cli.runner, + env: self.runner.as_deref(), + }, + fallback: SourceValue { + cli: cli.fallback, + env: self.fallback.as_deref(), + }, + on_mismatch: SourceValue { + cli: cli.on_mismatch, + env: self.on_mismatch.as_deref(), + }, + no_warnings: ExplainSource { + cli: cli.diagnostics.no_warnings, + env: self.no_warnings.as_deref(), + }, + explain: ExplainSource { + cli: cli.diagnostics.explain, + env: self.explain.as_deref(), + }, + keep_going: ExplainSource { + cli: cli.failure.keep_going, + env: self.keep_going.as_deref(), + }, + kill_on_fail: ExplainSource { + cli: cli.failure.kill_on_fail, + env: self.kill_on_fail.as_deref(), + }, + config, + } + } +} + +/// 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 +/// 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. +fn lenient_env_field( + field: &mut SourceValue<'_>, + var: &'static str, + warnings: &mut Vec, + validate: impl Fn(&str) -> Result<()>, +) { + if field.cli.map(str::trim).is_some_and(|s| !s.is_empty()) { + return; + } + let Some(raw) = field.env.map(str::trim).filter(|s| !s.is_empty()) else { + return; + }; + if let Err(err) = validate(raw) { + warnings.push(DetectionWarning::InvalidEnvOverride { + var, + raw: sanitize_raw_label(raw), + message: format!("{err}"), + }); + field.env = None; + } +} + /// Source names for the cross-ecosystem PM override. const PM_SOURCE_NAMES: SourceNames = SourceNames { cli: "--pm", diff --git a/src/resolver/policies.rs b/src/resolver/policies.rs index ba99b05..ff98582 100644 --- a/src/resolver/policies.rs +++ b/src/resolver/policies.rs @@ -30,7 +30,7 @@ pub(super) fn is_env_truthy(raw: &str) -> bool { && !v.eq_ignore_ascii_case("off") } -fn parse_fallback_label(raw: &str) -> Result { +pub(super) fn parse_fallback_label(raw: &str) -> Result { match raw { "probe" => Ok(FallbackPolicy::Probe), "npm" => Ok(FallbackPolicy::Npm), diff --git a/src/types.rs b/src/types.rs index 184ff99..4ce759b 100644 --- a/src/types.rs +++ b/src/types.rs @@ -236,6 +236,20 @@ pub(crate) enum DetectionWarning { /// the user can spot their typo without re-reading the file. raw: String, }, + /// An env-var override (`RUNNER_PM`, `RUNNER_RUNNER`) held a value + /// that doesn't parse, and the command chose to report it instead + /// of dying — `runner doctor` must be able to diagnose the broken + /// environment it exists to diagnose. Strict commands still treat + /// the same condition as a fatal error. + InvalidEnvOverride { + /// The variable that carried the value (`"RUNNER_PM"`). + var: &'static str, + /// The offending value, pre-sanitized for display (control + /// chars escaped, truncated). + raw: String, + /// Rendered parse error, already source-prefixed. + message: String, + }, } impl DetectionWarning { @@ -252,6 +266,7 @@ impl DetectionWarning { | Self::UnparseablePackageManager { .. } => "package.json", Self::PathProbeFallback { .. } | Self::LegacyNpmFallbackUsed { .. } => "resolver", Self::TaskListUnreadable { source, .. } => source, + Self::InvalidEnvOverride { .. } => "env", } } @@ -317,6 +332,9 @@ impl DetectionWarning { (expected one of npm|pnpm|yarn|bun|deno, optionally followed by @); \ declaration ignored, falling back to lockfile / PATH probe", ), + Self::InvalidEnvOverride { var, message, .. } => { + format!("{var} is set but invalid and was ignored for this report: {message}") + } } } } diff --git a/tests/doctor_env.rs b/tests/doctor_env.rs new file mode 100644 index 0000000..cdef957 --- /dev/null +++ b/tests/doctor_env.rs @@ -0,0 +1,137 @@ +//! Integration coverage for doctor's lenient handling of invalid +//! env-sourced overrides. +//! +//! `runner doctor` exists to diagnose a misconfigured environment, so a +//! garbage `RUNNER_PM` (e.g. PowerShell's unquoted `$env:RUNNER_PM=deno` +//! capturing deno's REPL banner) must degrade to a warning on the +//! report instead of killing the command. Every other command — and an +//! explicit `--pm` flag, even on doctor — stays strict. +//! +//! Env vars are injected per spawned child (never `std::env::set_var`), +//! so these tests are safe under the parallel test runner. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU32, Ordering}; + +fn runner_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_runner")) +} + +/// Minimal self-cleaning temp project: a directory holding only a +/// `Cargo.toml`, so detection finds exactly one PM (cargo) and the +/// doctor report is deterministic. +struct TempProject { + path: PathBuf, +} + +static NEXT_ID: AtomicU32 = AtomicU32::new(0); + +impl TempProject { + fn new(prefix: &str) -> Self { + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "runner-doctor-env-{prefix}-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&path).expect("create temp project dir"); + std::fs::write( + path.join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\n", + ) + .expect("write Cargo.toml"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// A value shaped like the PowerShell footgun output: multi-line, with +/// an ANSI escape. +const CAPTURED_BANNER: &str = "Deno 2.8.2 exit using ctrl+d\n\u{1b}[33mREPL is running\u{1b}[0m"; + +#[test] +fn doctor_survives_env_pm_garbage_and_reports_it() { + let project = TempProject::new("doctor-lenient"); + let output = Command::new(runner_binary()) + .args(["--dir", project.path().to_str().unwrap(), "doctor"]) + .env("RUNNER_PM", CAPTURED_BANNER) + .env_remove("RUNNER_RUNNER") + .output() + .expect("runner binary spawns"); + + assert!( + output.status.success(), + "doctor must survive env garbage. stderr: {}", + String::from_utf8_lossy(&output.stderr), + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + assert!( + combined.contains("RUNNER_PM"), + "the report must attribute the invalid override. output: {combined}", + ); + assert!( + combined.contains("ignored"), + "the report must say the value was ignored. output: {combined}", + ); +} + +#[test] +fn other_commands_stay_strict_on_env_pm_garbage() { + let project = TempProject::new("list-strict"); + let output = Command::new(runner_binary()) + .args(["--dir", project.path().to_str().unwrap(), "list"]) + .env("RUNNER_PM", CAPTURED_BANNER) + .env_remove("RUNNER_RUNNER") + .output() + .expect("runner binary spawns"); + + assert!( + !output.status.success(), + "non-doctor commands must keep failing on env garbage", + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("RUNNER_PM"), + "the error must name the env source. stderr: {stderr}", + ); +} + +#[test] +fn doctor_with_cli_pm_garbage_still_errors() { + let project = TempProject::new("doctor-cli-strict"); + let output = Command::new(runner_binary()) + .args([ + "--dir", + project.path().to_str().unwrap(), + "--pm", + "zoot", + "doctor", + ]) + .env_remove("RUNNER_PM") + .env_remove("RUNNER_RUNNER") + .output() + .expect("runner binary spawns"); + + assert!( + !output.status.success(), + "an explicit bad --pm flag must stay fatal, even on doctor", + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("unknown package manager"), + "stderr: {stderr}", + ); +} From 806e1972f1838174cec3c88d2492ba951680b359 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 11 Jun 2026 02:45:51 +0200 Subject: [PATCH 5/8] feat(doctor): resolve Volta shims to the real provisioned binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PATH probe reported Volta shims (e.g. C:\Program Files\Volta\ npm.EXE) as if they were the tools themselves. New tool::volta module classifies a probe hit as a shim by canonicalized parent-dir equality against the located volta binary dir and $VOLTA_HOME/bin, then asks `volta which ` (cwd = project root, so project pinning applies) for the real binary — classified by exit status and stdout only, never error-text parsing. Doctor renders `npm= -> (volta)` or `(volta shim, not provisioned)`; JSON gains the additive signals.node.volta_shims map behind a builder flag (doctor/info: on, list and unit tests: off, so no hot path spawns volta). Probe-fallback filtering of unprovisioned phantom shims is deliberately deferred. --- CHANGELOG.md | 12 +++ src/cmd/doctor.rs | 73 ++++++++++++- src/cmd/info.rs | 3 +- src/cmd/list.rs | 2 +- src/schema/project.rs | 92 +++++++++++++--- src/tool/mod.rs | 2 + src/tool/volta.rs | 238 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 403 insertions(+), 19 deletions(-) create mode 100644 src/tool/volta.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index cc5cee4..98f28c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,18 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - [ ] Update the `[Unreleased]` compare link to the new tag. - [ ] Create and push a signed `vX.Y.Z` tag from `master`. +### Added + +- `runner doctor` (and `info --json`) now classify PATH-probe hits that + are Volta shims and resolve them to the real provisioned binary via + `volta which`: the `PATH probe` line shows + `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 — + execution still spawns the shim, which performs Volta's per-project + version selection. + ### Changed - `runner install` now honors the `--pm`/`RUNNER_PM` override: when set, diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index c4990cc..6abb689 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -34,7 +34,7 @@ pub(crate) fn doctor( json: bool, schema_version: u32, ) -> Result<()> { - let project = Project::build_with_schema(ctx, overrides, schema_version); + let project = Project::build_with_schema(ctx, overrides, schema_version, true); if json { println!("{}", serde_json::to_string_pretty(&project)?); @@ -176,13 +176,11 @@ fn print_human(report: &Value, overrides: &ResolutionOverrides) { ); } if let Some(probe) = node["path_probe"].as_object() { + let shims = node["volta_shims"].as_object(); let parts: Vec = probe .iter() .map(|(bin, path)| { - let val = path - .as_str() - .map_or_else(|| "not found".dimmed().to_string(), ToOwned::to_owned); - format!("{bin}={val}") + format_probe_entry(bin, path.as_str(), shims.and_then(|s| s.get(bin))) }) .collect(); writeln_field(out, "PATH probe", &parts.join(", ")); @@ -218,6 +216,22 @@ fn print_human(report: &Value, overrides: &ResolutionOverrides) { } } +/// Render one `PATH probe` entry. Four cases: +/// `npm=not found` (dimmed), `bun=`, +/// `npm= -> (volta)` for a provisioned Volta shim, and +/// `pnpm= (volta shim, not provisioned)` (dimmed suffix) when +/// Volta fronts the tool but has no version of it. +fn format_probe_entry(bin: &str, path: Option<&str>, shim: Option<&Value>) -> String { + let Some(path) = path else { + return format!("{bin}={}", "not found".dimmed()); + }; + match shim.map(|s| s["resolved"].as_str()) { + Some(Some(real)) => format!("{bin}={path} -> {real} {}", "(volta)".dimmed()), + Some(None) => format!("{bin}={path} {}", "(volta shim, not provisioned)".dimmed()), + None => format!("{bin}={path}"), + } +} + fn print_section(title: &str, fill: F) where F: FnOnce(&mut String), @@ -257,6 +271,55 @@ mod tests { } } + #[test] + fn format_probe_entry_renders_all_four_cases() { + use serde_json::json; + + use super::format_probe_entry; + + // Strip color control codes by asserting on substrings only. + let not_found = format_probe_entry("npm", None, None); + assert!(not_found.starts_with("npm="), "{not_found}"); + assert!(not_found.contains("not found"), "{not_found}"); + + let plain = format_probe_entry("bun", Some(r"C:\bun\bun.EXE"), None); + assert!(plain.contains(r"bun=C:\bun\bun.EXE"), "{plain}"); + assert!(!plain.contains("volta"), "{plain}"); + + let shim = json!({ "resolved": r"C:\Volta\image\npm\11.6.2\npm.cmd" }); + let resolved = format_probe_entry("npm", Some(r"C:\Volta\npm.EXE"), Some(&shim)); + assert!( + resolved.contains(r"npm=C:\Volta\npm.EXE -> C:\Volta\image\npm\11.6.2\npm.cmd"), + "{resolved}" + ); + assert!(resolved.contains("(volta)"), "{resolved}"); + + let phantom = json!({ "resolved": null }); + let unprovisioned = format_probe_entry("pnpm", Some(r"C:\Volta\pnpm.EXE"), Some(&phantom)); + assert!( + unprovisioned.contains("volta shim, not provisioned"), + "{unprovisioned}" + ); + } + + #[test] + fn build_report_omits_volta_shims_when_not_resolving() { + let ctx = context(); + let report = build_report(&ctx, &ResolutionOverrides::default()); + + // `Project::build` passes `resolve_shims = false`; the additive + // field must vanish entirely, keeping the v1/v2 shape untouched. + assert!( + report["signals"]["node"].get("volta_shims").is_none(), + "volta_shims must be omitted when empty: {}", + report["signals"]["node"], + ); + assert!( + report["signals"]["node"].get("path_probe").is_some(), + "path_probe shape must be unchanged", + ); + } + #[test] fn build_report_includes_schema_version() { let ctx = context(); diff --git a/src/cmd/info.rs b/src/cmd/info.rs index 2d75424..546e21d 100644 --- a/src/cmd/info.rs +++ b/src/cmd/info.rs @@ -29,7 +29,8 @@ pub(crate) fn info( schema_version: u32, ) -> Result<()> { if json { - let view = Project::build_with_schema(ctx, overrides, schema_version).into_info_view(); + let view = + Project::build_with_schema(ctx, overrides, schema_version, true).into_info_view(); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(()); } diff --git a/src/cmd/list.rs b/src/cmd/list.rs index 8a240a2..6e9fce7 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -46,7 +46,7 @@ pub(crate) fn list( }; if json { - let view = Project::build_with_schema(ctx, overrides, schema_version) + let view = Project::build_with_schema(ctx, overrides, schema_version, false) .into_list_view(parsed_source); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(()); diff --git a/src/schema/project.rs b/src/schema/project.rs index 0fdd3d7..61956d8 100644 --- a/src/schema/project.rs +++ b/src/schema/project.rs @@ -62,7 +62,9 @@ impl<'a> Project<'a> { /// passes a concrete version to [`Self::build_with_schema`]. #[cfg(test)] pub(crate) fn build(ctx: &'a ProjectContext, overrides: &ResolutionOverrides) -> Self { - Self::build_with_schema(ctx, overrides, super::CURRENT_VERSION) + // `resolve_shims = false` keeps unit tests hermetic — no `volta + // which` spawns against the test host. + Self::build_with_schema(ctx, overrides, super::CURRENT_VERSION, false) } /// Build the report against a specific schema version. `schema_version` @@ -72,10 +74,16 @@ impl<'a> Project<'a> { /// Per-field versioning: source labels route through /// [`super::labels::source_label_for`]. PM and `TaskRunner` labels /// are unchanged across versions. + /// + /// `resolve_shims` controls whether PATH-probe hits are classified + /// against a Volta installation (one `volta which` spawn per + /// shimmed tool). Diagnostic surfaces (`doctor`, `info --json`) + /// pass `true`; `list` passes `false` — it drops signals anyway. pub(crate) fn build_with_schema( ctx: &'a ProjectContext, overrides: &ResolutionOverrides, schema_version: u32, + resolve_shims: bool, ) -> Self { let manifest_decl = detect_pm_from_manifest(&ctx.root); let manifest_pm = manifest_decl.as_ref().map(|d| ManifestPm { @@ -109,6 +117,8 @@ impl<'a> Project<'a> { }) .collect(); + let probes = probe_signals(&ctx.root, resolve_shims); + Self { schema_version, root: ctx.root.display().to_string(), @@ -123,7 +133,8 @@ impl<'a> Project<'a> { node: NodeSignals { lockfile_pm: ctx.primary_node_pm().map(PackageManager::label), manifest_pm, - path_probe: path_probe_map(), + path_probe: probes.path_probe, + volta_shims: probes.volta_shims, }, }, decisions, @@ -306,6 +317,20 @@ pub(crate) struct NodeSignals { pub manifest_pm: Option, /// `bun`/`pnpm`/`yarn`/`npm` -> absolute path on `$PATH` (or null). pub path_probe: BTreeMap<&'static str, Option>, + /// PATH-probe hits identified as Volta shims, keyed like + /// [`Self::path_probe`]. Additive field (no schema bump): absent on + /// hosts without Volta and on surfaces that skip shim resolution. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub volta_shims: BTreeMap<&'static str, VoltaShimInfo>, +} + +/// What `volta which` said about one shimmed tool. +#[derive(Debug, Serialize)] +pub(crate) struct VoltaShimInfo { + /// Real provisioned binary behind the shim; `null` when Volta has + /// no version of the tool ("not provisioned"). Shims Volta could + /// not classify at all are omitted from the map instead of guessed. + pub resolved: Option, } /// Manifest-level PM declaration plus the field it came from. @@ -458,13 +483,30 @@ const PATH_PROBE_PMS: [PackageManager; 4] = [ PackageManager::Npm, ]; -fn path_probe_map() -> BTreeMap<&'static str, Option> { +/// Probe results for the signals section: every PATH hit, plus Volta +/// shim classification when requested. +struct ProbeSignals { + path_probe: BTreeMap<&'static str, Option>, + volta_shims: BTreeMap<&'static str, VoltaShimInfo>, +} + +fn probe_signals(root: &std::path::Path, resolve_shims: bool) -> ProbeSignals { use std::env; use std::thread; + use crate::tool::volta::{ShimResolution, VoltaInstall}; + let path = env::var_os("PATH").unwrap_or_default(); let pathext = env::var_os("PATHEXT"); let pathext_ref = pathext.as_deref(); + // Located once, shared by every probe thread. `None` either means + // "no Volta on this host" or "shim resolution not requested" — + // both collapse to "classify nothing". + let volta = if resolve_shims { + VoltaInstall::locate() + } else { + None + }; thread::scope(|s| { // Spawn all probes first (push, don't lazy-iterate) so they @@ -475,20 +517,46 @@ fn path_probe_map() -> BTreeMap<&'static str, Option> { let mut handles = Vec::with_capacity(PATH_PROBE_PMS.len()); for pm in &PATH_PROBE_PMS { let path = &path; + let volta = volta.as_ref(); handles.push(s.spawn(move || { let resolved = - crate::resolver::probe_path_for_doctor(pm.label(), path, pathext_ref) - .map(|p| p.display().to_string()); - (pm.label(), resolved) + crate::resolver::probe_path_for_doctor(pm.label(), path, pathext_ref); + // The `volta which` spawn rides the same per-PM thread + // as the probe, so shim resolution adds one process + // wait of wall time, not four. + let shim = resolved + .as_deref() + .filter(|hit| volta.is_some_and(|v| v.is_shim(hit))) + .map(|_| crate::tool::volta::resolve_shim(pm.label(), root)); + (pm.label(), resolved.map(|p| p.display().to_string()), shim) })); } - let mut map = BTreeMap::new(); + let mut path_probe = BTreeMap::new(); + let mut volta_shims = BTreeMap::new(); for handle in handles { - let (label, resolved) = handle.join().expect("path probe thread panicked"); - map.insert(label, resolved); + let (label, resolved, shim) = handle.join().expect("path probe thread panicked"); + path_probe.insert(label, resolved); + match shim { + Some(ShimResolution::Resolved(real)) => { + volta_shims.insert( + label, + VoltaShimInfo { + resolved: Some(real.display().to_string()), + }, + ); + } + Some(ShimResolution::NotProvisioned) => { + volta_shims.insert(label, VoltaShimInfo { resolved: None }); + } + // Unknown: volta failed to answer — claim nothing. + Some(ShimResolution::Unknown) | None => {} + } + } + ProbeSignals { + path_probe, + volta_shims, } - map }) } @@ -593,12 +661,12 @@ mod tests { warnings: Vec::new(), }; - let v1 = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), 1); + let v1 = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), 1, false); let v1_json = serde_json::to_value(&v1).expect("v1 serialization"); assert_eq!(v1_json["schema_version"], 1); assert_eq!(v1_json["tasks"][0]["source"], "justfile"); - let v2 = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), 2); + let v2 = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), 2, false); let v2_json = serde_json::to_value(&v2).expect("v2 serialization"); assert_eq!(v2_json["schema_version"], 2); assert_eq!(v2_json["tasks"][0]["source"], "just"); diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 2ebc857..0c34149 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -60,6 +60,8 @@ pub(crate) mod python; pub(crate) mod turbo; /// uv, a fast Python package manager (`uv.lock`). pub(crate) mod uv; +/// Volta toolchain manager — shim classification and `volta which` resolution. +pub(crate) mod volta; /// Yarn, a Node.js package manager (`yarn.lock`). pub(crate) mod yarn; diff --git a/src/tool/volta.rs b/src/tool/volta.rs new file mode 100644 index 0000000..51ce9be --- /dev/null +++ b/src/tool/volta.rs @@ -0,0 +1,238 @@ +//! Volta toolchain manager — shim classification and `volta which` +//! resolution. +//! +//! Volta interposes shims for `node`/`npm`/`yarn`/`pnpm` on `PATH` +//! (Windows: next to `volta.exe`, e.g. `C:\Program Files\Volta\`; +//! Unix: `~/.volta/bin`). A shim exists even when the tool it fronts +//! was never provisioned, so a raw PATH probe can report a binary that +//! cannot actually run. This module classifies probe hits as shims and +//! resolves them to the real provisioned binary via `volta which`. +//! +//! Display/diagnostics only: execution always spawns the shim itself, +//! because the shim performs Volta's per-project version selection. + +use std::path::{Path, PathBuf}; + +use crate::tool::program; + +/// A located Volta installation reduced to its shim directories. +#[derive(Debug, Clone)] +pub(crate) struct VoltaInstall { + /// Canonicalized directories whose executables are Volta shims. + shim_dirs: Vec, +} + +impl VoltaInstall { + /// Locate Volta from the live environment: the directory holding + /// the `volta` binary on `PATH`, plus `$VOLTA_HOME/bin` when set. + /// Returns `None` when neither signal exists — no Volta, nothing + /// to classify. + pub(crate) fn locate() -> Option { + let volta_bin = std::env::var_os("PATH").and_then(|path| { + crate::resolver::probe_path_for_doctor( + "volta", + &path, + std::env::var_os("PATHEXT").as_deref(), + ) + }); + let volta_home = std::env::var_os("VOLTA_HOME").map(PathBuf::from); + Self::from_candidates(volta_bin.as_deref(), volta_home.as_deref()) + } + + /// Pure constructor for tests — injected candidates, no env reads. + pub(crate) fn from_candidates( + volta_bin: Option<&Path>, + volta_home: Option<&Path>, + ) -> Option { + let mut shim_dirs = Vec::new(); + if let Some(parent) = volta_bin.and_then(Path::parent) { + shim_dirs.push(canonical_dir(parent)); + } + if let Some(home) = volta_home { + shim_dirs.push(canonical_dir(&home.join("bin"))); + } + shim_dirs.dedup(); + if shim_dirs.is_empty() { + None + } else { + Some(Self { shim_dirs }) + } + } + + /// True when `bin` lives directly in one of the shim directories. + /// Exact parent-directory equality, not prefix matching — + /// `/nested/npm` is not a shim. Only the *parent* is + /// canonicalized: on Unix the shims themselves are symlinks to + /// `volta-shim`, and canonicalizing the file would escape the bin + /// directory entirely. + pub(crate) fn is_shim(&self, bin: &Path) -> bool { + let Some(parent) = bin.parent() else { + return false; + }; + let canonical = canonical_dir(parent); + self.shim_dirs.contains(&canonical) + } +} + +/// Canonicalize a directory for comparison (resolves `\\?\` prefixes +/// and on-disk casing on Windows); fall back to the lexical path when +/// canonicalization fails so comparison degrades instead of erroring. +fn canonical_dir(dir: &Path) -> PathBuf { + dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf()) +} + +/// Outcome of resolving one shim through `volta which`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ShimResolution { + /// The tool is provisioned; this is the real binary the shim runs. + Resolved(PathBuf), + /// Volta answered but has no version of the tool ("No default …"). + NotProvisioned, + /// Volta itself failed to answer (spawn error, empty output) — + /// claim nothing. + Unknown, +} + +/// Ask `volta which ` for the real provisioned binary. +/// +/// Runs with `project_root` as the working directory because `volta +/// which` honors the project pinning of its CWD. Classification uses +/// exit status and stdout only — Volta's error wording ("No default +/// npm version set") varies across versions and must not be parsed. +pub(crate) fn resolve_shim(tool: &str, project_root: &Path) -> ShimResolution { + match program::command("volta") + .args(["which", tool]) + .current_dir(project_root) + .output() + { + Ok(out) => classify_which_output(out.status.success(), &out.stdout), + Err(_) => ShimResolution::Unknown, + } +} + +/// Pure decoder for `volta which` output, split out so tests don't +/// need a Volta installation. +fn classify_which_output(success: bool, stdout: &[u8]) -> ShimResolution { + if !success { + return ShimResolution::NotProvisioned; + } + let text = String::from_utf8_lossy(stdout); + let trimmed = text.trim(); + if trimmed.is_empty() { + ShimResolution::Unknown + } else { + ShimResolution::Resolved(PathBuf::from(trimmed)) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::{Path, PathBuf}; + + use super::{ShimResolution, VoltaInstall, classify_which_output}; + use crate::tool::test_support::TempDir; + + #[test] + fn from_candidates_uses_parent_of_volta_bin() { + let dir = TempDir::new("volta-bin-parent"); + let volta = dir.path().join("volta.exe"); + let npm = dir.path().join("npm.exe"); + fs::write(&volta, "").expect("write volta stub"); + fs::write(&npm, "").expect("write npm stub"); + + let install = + VoltaInstall::from_candidates(Some(&volta), None).expect("volta bin is evidence"); + assert!( + install.is_shim(&npm), + "sibling of volta must classify as shim" + ); + assert!( + !install.is_shim(Path::new("/somewhere/else/npm")), + "unrelated dirs must not classify" + ); + } + + #[test] + fn from_candidates_adds_volta_home_bin() { + let home = TempDir::new("volta-home"); + let bin = home.path().join("bin"); + fs::create_dir_all(&bin).expect("create bin dir"); + let yarn = bin.join("yarn"); + fs::write(&yarn, "").expect("write yarn stub"); + + let install = + VoltaInstall::from_candidates(None, Some(home.path())).expect("VOLTA_HOME is evidence"); + assert!(install.is_shim(&yarn)); + } + + #[test] + fn from_candidates_none_without_evidence() { + assert!(VoltaInstall::from_candidates(None, None).is_none()); + } + + #[test] + fn is_shim_requires_exact_parent_dir() { + let dir = TempDir::new("volta-exact-parent"); + let volta = dir.path().join("volta"); + fs::write(&volta, "").expect("write volta stub"); + let nested_dir = dir.path().join("nested"); + fs::create_dir_all(&nested_dir).expect("create nested dir"); + let nested = nested_dir.join("npm"); + fs::write(&nested, "").expect("write nested stub"); + + let install = + VoltaInstall::from_candidates(Some(&volta), None).expect("volta bin is evidence"); + assert!(!install.is_shim(&nested), "no prefix matching: {nested:?}"); + } + + #[test] + fn classify_which_output_resolves_trimmed_path() { + let resolved = classify_which_output(true, b"C:\\Volta\\image\\npm\\11.6.2\\npm.cmd\r\n"); + assert_eq!( + resolved, + ShimResolution::Resolved(PathBuf::from("C:\\Volta\\image\\npm\\11.6.2\\npm.cmd")), + ); + } + + #[test] + fn classify_which_output_nonzero_is_not_provisioned() { + assert_eq!( + classify_which_output(false, b""), + ShimResolution::NotProvisioned, + ); + } + + #[test] + fn classify_which_output_empty_stdout_is_unknown() { + assert_eq!( + classify_which_output(true, b" \n"), + ShimResolution::Unknown + ); + } + + /// Availability-gated smoke test: only meaningful on a host with + /// Volta installed; skips (with a note) elsewhere, mirroring the + /// `just`-gated integration tests. + #[test] + fn volta_which_smoke() { + let Some(path) = std::env::var_os("PATH") else { + eprintln!("skipping: no PATH"); + return; + }; + if crate::resolver::probe_path_for_doctor( + "volta", + &path, + std::env::var_os("PATHEXT").as_deref(), + ) + .is_none() + { + eprintln!("skipping: `volta` not found on PATH"); + return; + } + let cwd = std::env::current_dir().expect("cwd exists"); + // Any variant is acceptable; the assertion is "does not panic + // and answers something classifiable". + let _ = super::resolve_shim("node", &cwd); + } +} From 8806223325b997ceff4e37e56ad45889fec7e656 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 11 Jun 2026 20:07:19 +0200 Subject: [PATCH 6/8] fix(types): strip `=` in version prefix fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loose prefix fallback never stripped a bare `=` operator and stripped `v` before trimming inner whitespace, so `=20.11` and `>= v18` mis-cleaned when an unparseable `current` forced the fallback path. Trim first, strip operators incl. `=`, then strip the `v` prefix. Also scrub every inherited `RUNNER_*` var (case-insensitive, for Windows) from the doctor_env child processes — a dev box exporting `RUNNER_NO_WARNINGS` or `RUNNER_FALLBACK` could flip assertions. --- src/types.rs | 18 +++++++++++++++--- tests/doctor_env.rs | 29 ++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/types.rs b/src/types.rs index 4ce759b..ee05ca2 100644 --- a/src/types.rs +++ b/src/types.rs @@ -700,15 +700,17 @@ pub(crate) fn version_matches(expected: &str, current: &str) -> bool { /// whether `current` starts with the cleaned `expected` value at a /// segment boundary. A bare major version like `"20"` matches `"20.x.y"`. fn prefix_version_matches(expected: &str, current: &str) -> bool { - let expected_clean = expected + let after_ops = expected + .trim() .trim_start_matches(">=") .trim_start_matches("<=") .trim_start_matches('>') .trim_start_matches('<') + .trim_start_matches('=') .trim_start_matches('~') .trim_start_matches('^') - .trim_start_matches('v') - .trim(); + .trim_start(); + let expected_clean = strip_v(after_ops).trim(); current.starts_with(expected_clean) && current[expected_clean.len()..] @@ -954,6 +956,16 @@ mod tests { assert!(!version_matches(">=18", "not-a-version")); } + #[test] + fn prefix_fallback_strips_equals_and_spaced_v() { + // An unparseable `current` forces the prefix fallback; the + // cleaned expected value must survive a bare `=` operator and + // whitespace between the operator and a `v`-prefixed version. + assert!(version_matches("=20.11", "20.11.beta")); + assert!(!version_matches("=20.11", "20.12.beta")); + assert!(version_matches(">= v18", "18.unknown")); + } + #[test] fn detection_warning_can_be_hashed() { use std::collections::HashSet; diff --git a/tests/doctor_env.rs b/tests/doctor_env.rs index cdef957..98b57d5 100644 --- a/tests/doctor_env.rs +++ b/tests/doctor_env.rs @@ -18,6 +18,25 @@ fn runner_binary() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_runner")) } +/// Command for the runner binary with every inherited `RUNNER_*` +/// variable scrubbed, so only what a test sets explicitly reaches the +/// child. A dev box exporting e.g. `RUNNER_NO_WARNINGS` or +/// `RUNNER_FALLBACK` would otherwise flip these assertions. Matched +/// case-insensitively — Windows env lookups ignore case. +fn runner_command() -> Command { + let mut cmd = Command::new(runner_binary()); + for (key, _) in std::env::vars_os() { + if key + .to_string_lossy() + .to_ascii_uppercase() + .starts_with("RUNNER_") + { + cmd.env_remove(&key); + } + } + cmd +} + /// Minimal self-cleaning temp project: a directory holding only a /// `Cargo.toml`, so detection finds exactly one PM (cargo) and the /// doctor report is deterministic. @@ -61,10 +80,9 @@ const CAPTURED_BANNER: &str = "Deno 2.8.2 exit using ctrl+d\n\u{1b}[33mREPL is r #[test] fn doctor_survives_env_pm_garbage_and_reports_it() { let project = TempProject::new("doctor-lenient"); - let output = Command::new(runner_binary()) + let output = runner_command() .args(["--dir", project.path().to_str().unwrap(), "doctor"]) .env("RUNNER_PM", CAPTURED_BANNER) - .env_remove("RUNNER_RUNNER") .output() .expect("runner binary spawns"); @@ -91,10 +109,9 @@ fn doctor_survives_env_pm_garbage_and_reports_it() { #[test] fn other_commands_stay_strict_on_env_pm_garbage() { let project = TempProject::new("list-strict"); - let output = Command::new(runner_binary()) + let output = runner_command() .args(["--dir", project.path().to_str().unwrap(), "list"]) .env("RUNNER_PM", CAPTURED_BANNER) - .env_remove("RUNNER_RUNNER") .output() .expect("runner binary spawns"); @@ -112,7 +129,7 @@ fn other_commands_stay_strict_on_env_pm_garbage() { #[test] fn doctor_with_cli_pm_garbage_still_errors() { let project = TempProject::new("doctor-cli-strict"); - let output = Command::new(runner_binary()) + let output = runner_command() .args([ "--dir", project.path().to_str().unwrap(), @@ -120,8 +137,6 @@ fn doctor_with_cli_pm_garbage_still_errors() { "zoot", "doctor", ]) - .env_remove("RUNNER_PM") - .env_remove("RUNNER_RUNNER") .output() .expect("runner binary spawns"); From 1007270ac415f2b6891e72283fcac218ca4348a9 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Thu, 11 Jun 2026 21:03:55 +0200 Subject: [PATCH 7/8] test(resolver): cover lenient env warning truncation --- src/resolver/overrides.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/resolver/overrides.rs b/src/resolver/overrides.rs index cb45552..d5b137d 100644 --- a/src/resolver/overrides.rs +++ b/src/resolver/overrides.rs @@ -287,6 +287,40 @@ fn sanitize_raw_label(raw: &str) -> String { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lenient_policy_env_garbage_does_not_leak_full_raw_value() { + let token_prefix = "ghp_"; + let fake_token = format!( + "{token_prefix}{}DO_NOT_LEAK_ME", + "A".repeat(MAX_RAW_DISPLAY.saturating_sub(token_prefix.len())) + ); + let huge = fake_token.repeat(6); + let (_overrides, warnings) = ResolutionOverrides::from_sources_lenient(OverrideSources { + fallback: SourceValue { + cli: None, + env: Some(&huge), + }, + ..OverrideSources::default() + }) + .expect("lenient pass must absorb fallback env garbage"); + + assert_eq!(warnings.len(), 1); + let detail = warnings[0].detail(); + assert!( + detail.contains('…'), + "long invalid env value should be truncated in warning detail" + ); + assert!( + !detail.contains("DO_NOT_LEAK_ME"), + "secret-looking env tail must not leak in warning detail" + ); + } +} + /// The CLI-flag half of an override assembly, bundled so /// [`EnvSnapshot::sources`] pairs one CLI side with one env snapshot /// instead of threading seven loose parameters. From 4b81e62a2bca077831ac92ce005fb04706b421dc Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Fri, 12 Jun 2026 12:19:12 +0200 Subject: [PATCH 8/8] feat(schema): emit JSON output schemas --- .dprint.jsonc | 21 +- .github/workflows/pages.yml | 14 + .zed/settings.json | 22 +- Cargo.lock | 35 +- Cargo.toml | 7 +- bin/.runner-dev | 50 ++ bin/run | 24 +- bin/runner | 24 +- justfile | 8 +- npm/targets.schema.json | 404 ++++++++----- package.json | 1 + rust-toolchain.toml | 2 +- rustfmt.toml | 1 + schemas/doctor.v1.example.json | 234 ++++++++ schemas/doctor.v1.schema.json | 471 +++++++++++++++ schemas/doctor.v2.example.json | 234 ++++++++ schemas/doctor.v2.schema.json | 471 +++++++++++++++ schemas/doctor.v3-draft.schema.json | 880 ++++++++++++++++++++++++++++ schemas/list.v1.example.json | 173 ++++++ schemas/list.v1.schema.json | 92 +++ schemas/list.v2.example.json | 173 ++++++ schemas/list.v2.schema.json | 92 +++ schemas/runner.toml.schema.json | 110 ++-- schemas/why.v1.example.json | 29 + schemas/why.v1.schema.json | 192 ++++++ schemas/why.v2.example.json | 29 + schemas/why.v2.schema.json | 192 ++++++ schemas/why.v3-draft.example.json | 67 +++ src/cli.rs | 9 +- src/cmd/schema.rs | 215 ++++++- src/cmd/why.rs | 158 +++-- src/lib.rs | 6 +- src/resolver/overrides.rs | 10 +- src/schema/project.rs | 39 ++ 34 files changed, 4151 insertions(+), 338 deletions(-) create mode 100644 .github/workflows/pages.yml create mode 100755 bin/.runner-dev mode change 100755 => 120000 bin/run mode change 100755 => 120000 bin/runner create mode 100644 rustfmt.toml create mode 100644 schemas/doctor.v1.example.json create mode 100644 schemas/doctor.v1.schema.json create mode 100644 schemas/doctor.v2.example.json create mode 100644 schemas/doctor.v2.schema.json create mode 100644 schemas/doctor.v3-draft.schema.json create mode 100644 schemas/list.v1.example.json create mode 100644 schemas/list.v1.schema.json create mode 100644 schemas/list.v2.example.json create mode 100644 schemas/list.v2.schema.json create mode 100644 schemas/why.v1.example.json create mode 100644 schemas/why.v1.schema.json create mode 100644 schemas/why.v2.example.json create mode 100644 schemas/why.v2.schema.json create mode 100644 schemas/why.v3-draft.example.json diff --git a/.dprint.jsonc b/.dprint.jsonc index a02223e..32f1582 100644 --- a/.dprint.jsonc +++ b/.dprint.jsonc @@ -1,6 +1,7 @@ { + "$schema": "https://dprint.dev/schemas/v0.json", "useTabs": true, - "json": {}, + "json": { "jsonTrailingCommaFiles": [".zed/settings.json"], "trailingCommas": "jsonc" }, "markdown": { "textWrap": "maintain", "emphasisKind": "asterisks" }, "malva": {}, "markup": { @@ -10,13 +11,14 @@ "printWidth": 160, }, "shfmt": { - "associations": ["**/bin/{install-branch,run,runner}", "**/*.{bash,zsh,sh}"], + "associations": ["**/bin/{install-branch,.runner-dev}", "**/*.{bash,zsh,sh}"], "useTabs": true, "binaryNextLine": true, "switchCaseIndent": true, "experimentalZsh": true, }, "yaml": { "printWidth": 160 }, + "jsonSchemaSort": { "associations": ["**/schema.json", "**/*.schema.json"] }, "exec": { "commands": [ { "command": "tombi format - --stdin-filename {{file_path}}", "exts": ["toml"] }, @@ -31,19 +33,20 @@ "**/target", "**/dist", "**/downloads", - "**/schemas/*.json", + "schemas/*.example.json", ], "plugins": [ - "https://plugins.dprint.dev/typescript-0.96.1.wasm", - "https://plugins.dprint.dev/json-0.21.3.wasm", - "https://plugins.dprint.dev/markdown-0.22.1.wasm", + "https://plugins.dprint.dev/dockerfile-0.4.0.wasm", + "https://plugins.dprint.dev/exec-0.6.2.json@df98f54ffd3092b8a841aedd6d098a2651f16d0a796a40535774f1a8b4b9d463", "https://plugins.dprint.dev/g-plane/malva-v0.16.0.wasm", "https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.3.wasm", "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm", - "https://plugins.dprint.dev/exec-0.6.2.json@df98f54ffd3092b8a841aedd6d098a2651f16d0a796a40535774f1a8b4b9d463", + "https://plugins.dprint.dev/json-0.21.3.wasm", + "https://plugins.dprint.dev/kjanat/json-schema-sort-0.1.0.wasm", + "https://plugins.dprint.dev/kjanat/shfmt-1.0.0.wasm", "https://plugins.dprint.dev/kjanat/sortpackagejson-0.2.1.wasm", "https://plugins.dprint.dev/kjanat/svg-v0.4.1.wasm", - "https://plugins.dprint.dev/kjanat/shfmt-1.0.0.wasm", - "https://plugins.dprint.dev/dockerfile-0.4.0.wasm", + "https://plugins.dprint.dev/markdown-0.22.1.wasm", + "https://plugins.dprint.dev/typescript-0.96.1.wasm", ], } diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..dd99152 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,14 @@ +--- +name: Upload schemas to GitHub Pages +on: { push: { branches: [master], paths: [schemas/**] } } +permissions: { contents: read, id-token: write, pages: write } +jobs: + upload: + runs-on: ubuntu-slim + environment: { name: github-pages, url: "${{ steps.deployment.outputs.page_url }}" } + steps: [ + { uses: actions/checkout@v6, with: { sparse-checkout: schemas/*.schema.json } }, + { uses: actions/configure-pages@v5, with: { enablement: true }, id: conf }, + { uses: actions/upload-pages-artifact@v4, with: { path: . } }, + { uses: actions/deploy-pages@v4, id: deployment }, + ] diff --git a/.zed/settings.json b/.zed/settings.json index a89c499..4cbf194 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,7 +1,3 @@ -// Folder-specific settings -// -// For a full list of overridable settings, and general information on folder-specific settings, -// see the documentation: https://zed.dev/docs/configuring-zed#settings-files { "formatter": [ { "language_server": { "name": "dprint" } }, @@ -10,7 +6,21 @@ "prettier": { "allowed": false }, "lsp": { "json-language-server": { - "settings": { "json": { "schemas": [{ "fileMatch": ["targets.json"], "url": "./npm/targets.schema.json" }] } } + "settings": { + "json": { + "schemas": [ + { "fileMatch": ["targets.json"], "url": "./npm/targets.schema.json" }, + { "fileMatch": ["schemas/*.schema.json", "*.schema.json"], "url": "https://json-schema.org/draft/2020-12/schema" }, + { "fileMatch": ["schemas/doctor.v1.example.json", "doctor.v1.example.json"], "url": "./schemas/doctor.v1.schema.json" }, + { "fileMatch": ["schemas/doctor.v2.example.json", "doctor.v2.example.json"], "url": "./schemas/doctor.v2.schema.json" }, + { "fileMatch": ["schemas/list.v1.example.json", "list.v1.example.json"], "url": "./schemas/list.v1.schema.json" }, + { "fileMatch": ["schemas/list.v2.example.json", "list.v2.example.json"], "url": "./schemas/list.v2.schema.json" }, + { "fileMatch": ["schemas/why.v1.example.json", "why.v1.example.json"], "url": "./schemas/why.v1.schema.json" }, + { "fileMatch": ["schemas/why.v2.example.json", "why.v2.example.json"], "url": "./schemas/why.v2.schema.json" }, + { "fileMatch": ["schemas/doctor.v3-draft.example.json", "doctor.v3-draft.example.json"], "url": "./schemas/doctor.v3-draft.schema.json" } + ] + } + } }, "rust-analyzer": { "initialization_options": { @@ -20,6 +30,6 @@ } } }, - "file_types": { "SVG": ["*.ico"] }, + "file_types": { "SVG": ["*.ico"], "Shell Script": [".runner-dev", "justfile"] }, "languages": { "TOML": { "language_servers": ["tombi", "..."] } } } diff --git a/Cargo.lock b/Cargo.lock index ba4e4bb..210205d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -200,13 +206,19 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -215,6 +227,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "is_executable" version = "1.0.5" @@ -236,6 +258,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "json-schema-sort" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0064742d7d654ade52f6a62cf9fed7a9bfe2434b96319d31ebbb60d54267f35" +dependencies = [ + "serde_json", +] + [[package]] name = "json5" version = "1.3.1" @@ -324,6 +355,7 @@ dependencies = [ "clap_complete", "clap_mangen", "colored", + "json-schema-sort", "json5", "schemars", "semver", @@ -427,6 +459,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index c558d13..dfdd284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,11 @@ features = ["unstable-dynamic"] version = "0.3" optional = true +[dependencies.json-schema-sort] +version = "0.1" +default-features = false +optional = true + [dependencies.schemars] version = "1.2" optional = true @@ -95,7 +100,7 @@ features = ["parse", "serde"] default = ["run"] man = ["dep:clap_mangen"] run = [] -schema = ["dep:schemars"] +schema = ["dep:json-schema-sort", "dep:schemars"] [lints.clippy] all = { level = "deny", priority = -1 } diff --git a/bin/.runner-dev b/bin/.runner-dev new file mode 100755 index 0000000..513a306 --- /dev/null +++ b/bin/.runner-dev @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +invoked_name="$(basename -- "$0")" + +case "${invoked_name}" in + run | runner) ;; + *) + printf 'error: unsupported launcher name: %s\n' "${invoked_name}" >&2 + exit 1 + ;; +esac + +source_path="${BASH_SOURCE[0]}" + +while [[ -L "${source_path}" ]]; do + source_dir="$( + unset CDPATH + cd -- "$(dirname -- "${source_path}")" + pwd -P + )" + + link_target="$(readlink -- "${source_path}")" + + case "${link_target}" in + /*) source_path="${link_target}" ;; + *) source_path="${source_dir}/${link_target}" ;; + esac +done + +script_dir="$( + unset CDPATH + cd -- "$(dirname -- "${source_path}")" + pwd -P +)" + +manifest="${script_dir}/../Cargo.toml" +target_dir="${script_dir}/../target/runner-dev" + +[[ -f "${manifest}" ]] || { + printf 'error: %s not found\n' "${manifest}" >&2 + exit 1 +} + +exec cargo run --locked --quiet \ + --bin "${invoked_name}" \ + --features=run,man,schema \ + --manifest-path="${manifest}" \ + --target-dir="${target_dir}" \ + -- "$@" diff --git a/bin/run b/bin/run deleted file mode 100755 index bc19152..0000000 --- a/bin/run +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -SOURCE_PATH="$0" -while [ -L "${SOURCE_PATH}" ]; do - # shellcheck disable=SC1007 - SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "${SOURCE_PATH}")" && pwd)" - LINK_TARGET="$(readlink "${SOURCE_PATH}")" - case "${LINK_TARGET}" in - /*) SOURCE_PATH="${LINK_TARGET}" ;; - *) SOURCE_PATH="${SOURCE_DIR}/${LINK_TARGET}" ;; - esac -done -# shellcheck disable=SC1007 -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${SOURCE_PATH}")" && pwd)" -MANIFEST="${SCRIPT_DIR}/../Cargo.toml" -[ -f "${MANIFEST}" ] || { - printf 'error: %s not found\n' "${MANIFEST}" >&2 - exit 1 -} - -exec env CARGO_TERM_QUIET=true \ - cargo run --release --bin run \ - --manifest-path="${MANIFEST}" \ - -- "$@" diff --git a/bin/run b/bin/run new file mode 120000 index 0000000..bac4f91 --- /dev/null +++ b/bin/run @@ -0,0 +1 @@ +.runner-dev \ No newline at end of file diff --git a/bin/runner b/bin/runner deleted file mode 100755 index f706c49..0000000 --- a/bin/runner +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -SOURCE_PATH="$0" -while [[ -L "${SOURCE_PATH}" ]]; do - # shellcheck disable=SC1007 - SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "${SOURCE_PATH}")" && pwd)" - LINK_TARGET="$(readlink "${SOURCE_PATH}")" - case "${LINK_TARGET}" in - /*) SOURCE_PATH="${LINK_TARGET}" ;; - *) SOURCE_PATH="${SOURCE_DIR}/${LINK_TARGET}" ;; - esac -done -# shellcheck disable=SC1007 -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${SOURCE_PATH}")" && pwd)" -MANIFEST="${SCRIPT_DIR}/../Cargo.toml" -[[ -f "${MANIFEST}" ]] || { - printf 'error: %s not found\n' "${MANIFEST}" >&2 - exit 1 -} - -exec env CARGO_TERM_QUIET=true \ - cargo run --release --locked \ - --manifest-path="${MANIFEST}" \ - -- "$@" diff --git a/bin/runner b/bin/runner new file mode 120000 index 0000000..bac4f91 --- /dev/null +++ b/bin/runner @@ -0,0 +1 @@ +.runner-dev \ No newline at end of file diff --git a/justfile b/justfile index b1320c3..f6e86d9 100644 --- a/justfile +++ b/justfile @@ -8,7 +8,7 @@ npm-pkg-scope := `cargo metadata --format-version 1 --no-deps | jq -r --arg id " build-pkgscript := "npm" / "scripts" / "build-packages.ts" downloads-dir := "npm" / "downloads" -schema := "schemas" / "runner.toml.schema.json" +schema-dir := "schemas" [arg('bin', pattern='run|runner')] [arg('profile', pattern='dev|release|')] @@ -27,12 +27,12 @@ runner *args: ls: @just --list -# Regenerate the committed JSON Schema for `runner.toml`. +# Regenerate the committed JSON Schemas. # Drift guard: just gen-schema && git diff --exit-code schemas/ [group('schema')] gen-schema: - @echo "→ regenerating {{ BLUE }}{{ schema }}{{ NORMAL }}" - @cargo schema --output {{ schema }} + @echo "→ regenerating {{ BLUE }}{{ schema-dir }}{{ NORMAL }}" + @cargo schema --all --output {{ schema-dir }} [group('npm')] build-packages only="" skip="false" version=cargo-version: diff --git a/npm/targets.schema.json b/npm/targets.schema.json index 0441761..631dbc5 100644 --- a/npm/targets.schema.json +++ b/npm/targets.schema.json @@ -1,155 +1,253 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/kjanat/runner/blob/master/npm/targets.schema.json", - "title": "runner npm targets matrix", - "description": "Build matrix for the runner-run façade and its per-platform optionalDependencies. Consumed by npm/scripts/build-packages.ts and the release workflow.", - "type": "object", - "additionalProperties": false, - "required": ["facade", "scope", "binaries", "targets"], - "properties": { - "$schema": { - "type": "string", - "description": "Pointer to this schema for editor tooling.", - "examples": ["https://github.com/kjanat/runner/blob/master/npm/targets.schema.json", "./targets.schema.json"] - }, - "facade": { - "type": "string", - "description": "npm package name of the façade (the package users install). \nResolves to the correct platform sub-package via \"optionalDependencies\".", - "markdownDescription": "# Facade package name \nnpm `package` name of the façade (the package users install). \nResolves to the correct platform sub-package via `optionalDependencies`.", - "minLength": 1, - "default": "runner-run", - "examples": ["runner-run"] - }, - "scope": { - "type": "string", - "description": "npm scope under which platform sub-packages are published. \nEach sub-package is published as \"/\".", - "markdownDescription": "# npm scope \nScope under which platform sub-packages are published. \nEach sub-package is published as `/`.", - "pattern": "^@[a-z0-9][a-z0-9-]*$", - "default": "@runner-run", - "examples": ["@runner-run"] - }, - "binaries": { - "type": "array", - "description": "Binary names extracted from each release tarball and shipped in every platform sub-package's \"bin/\" directory. \nOn Windows, \".exe\" is appended automatically.", - "markdownDescription": "# Binary names \nBinary names extracted from each release tarball and shipped in every platform sub-package's `bin/` directory. \nOn Windows, `.exe` is appended automatically.", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - } - }, - "targets": { - "type": "array", - "description": "Per-platform build targets. \nOne entry per published sub-package.", - "markdownDescription": "# Build targets \nPer-platform build targets. One entry per published sub-package.", - "minItems": 1, - "items": { "$ref": "#/$defs/target" } - } - }, - "$defs": { - "target": { - "type": "object", - "description": "Build target for a specific platform/configuration. \nUsed to generate release tarballs and platform-specific sub-packages.", - "markdownDescription": "# Build target \nBuild target for a specific platform/configuration. \nUsed to generate release tarballs and platform-specific sub-packages.", - "additionalProperties": false, - "required": ["pkg", "rust", "os", "cpu", "runner", "build", "tier"], - "properties": { - "pkg": { - "type": "string", - "description": "Final package name is \"/\".", - "markdownDescription": "# Sub-package name suffix. \nFinal package name is `/`. \n- [NPM Scope](https://docs.npmjs.com/cli/v11/using-npm/scope)\n- [NPM Package name rules](https://docs.npmjs.com/cli/v11/using-npm/package-spec#package-name)\n- [NPM `package.json#name`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name)", - "pattern": "^[a-z0-9][a-z0-9-]*$" - }, - "rust": { - "type": "string", - "description": "Passed to \"cargo build --target\" / \"cross build --target\". \nAlso used to locate the release tarball: \"runner--.tar.gz\".", - "markdownDescription": "# Rust target triple \nPassed to `cargo build --target` / `cross build --target`. \nAlso used to locate the release tarball: `runner--.tar.gz`.", - "pattern": "^[a-z0-9_]+(-[a-z0-9_]+){2,3}$" - }, - "os": { - "type": "array", - "markdownTitle": "# Operating system", - "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.", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "enum": ["aix", "darwin", "freebsd", "linux", "netbsd", "openbsd", "sunos", "win32"] - } - }, - "cpu": { - "type": "array", - "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).", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "enum": ["arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc64", "riscv64", "s390", "s390x", "x64"] - } - }, - "libc": { - "type": "array", - "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.", - "minItems": 1, - "uniqueItems": true, - "items": { "type": "string", "enum": ["glibc", "musl"] } - }, - "runner": { - "type": "string", - "description": "Hosted/self-hosted runner labels.", - "markdownDescription": "# GitHub Actions runner label \nHosted/self-hosted runner labels.", - "minLength": 1, - "examples": ["ubuntu-latest", "macos-latest", "macos-15-intel", "windows-latest", "windows-11-arm"] - }, - "build": { - "type": "string", - "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.", - "enum": ["cargo", "cross", "cargo-cross-toolchain", "cargo-build-std", "vm"] - }, - "tier": { - "type": "integer", - "description": "1 = first-class (release-blocking), \n2 = best-effort, \n3 = experimental (failures don't block release).", - "markdownDescription": "# Support tier \n1 = first-class (release-blocking), \n2 = best-effort, \n3 = experimental (failures don't block release).", - "enum": [1, 2, 3] - }, - "experimental": { - "type": "boolean", - "title": "Experimental flag", - "description": "Used by the release workflow as \"continue-on-error\". \nOnly valid on tier-3 targets.", - "default": false - } - }, - "dependentSchemas": { - "libc": { - "description": "libc is only meaningful on Linux. If declared, os must include linux.", - "properties": { "os": { "type": "array", "contains": { "const": "linux" } } }, - "required": ["os"] - } - }, - "allOf": [{ - "if": { - "properties": { "os": { "type": "array", "contains": { "const": "linux" } } }, - "required": ["os"] - }, - "then": { - "required": ["libc"], - "description": "Linux targets must declare libc to disambiguate glibc and musl builds at install time." - } - }, { - "if": { - "properties": { "experimental": { "const": true } }, - "required": ["experimental"] - }, - "then": { - "properties": { "tier": { "const": 3 } }, - "description": "Only tier-3 targets may be marked experimental." - } - }] - } - } + "$id": "https://github.com/kjanat/runner/blob/master/npm/targets.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "target": { + "description": "Build target for a specific platform/configuration. \nUsed to generate release tarballs and platform-specific sub-packages.", + "markdownDescription": "# Build target \nBuild target for a specific platform/configuration. \nUsed to generate release tarballs and platform-specific sub-packages.", + "type": "object", + "required": [ + "build", + "cpu", + "os", + "pkg", + "runner", + "rust", + "tier" + ], + "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.", + "type": "string", + "enum": [ + "cargo", + "cross", + "cargo-cross-toolchain", + "cargo-build-std", + "vm" + ] + }, + "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).", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "arm", + "arm64", + "ia32", + "loong64", + "mips", + "mipsel", + "ppc64", + "riscv64", + "s390", + "s390x", + "x64" + ] + } + }, + "experimental": { + "title": "Experimental flag", + "description": "Used by the release workflow as \"continue-on-error\". \nOnly valid on tier-3 targets.", + "default": false, + "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.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "glibc", + "musl" + ] + } + }, + "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.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "aix", + "darwin", + "freebsd", + "linux", + "netbsd", + "openbsd", + "sunos", + "win32" + ] + }, + "markdownTitle": "# Operating system" + }, + "pkg": { + "description": "Final package name is \"/\".", + "markdownDescription": "# Sub-package name suffix. \nFinal package name is `/`. \n- [NPM Scope](https://docs.npmjs.com/cli/v11/using-npm/scope)\n- [NPM Package name rules](https://docs.npmjs.com/cli/v11/using-npm/package-spec#package-name)\n- [NPM `package.json#name`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name)", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "runner": { + "description": "Hosted/self-hosted runner labels.", + "markdownDescription": "# GitHub Actions runner label \nHosted/self-hosted runner labels.", + "examples": [ + "ubuntu-latest", + "macos-latest", + "macos-15-intel", + "windows-latest", + "windows-11-arm" + ], + "type": "string", + "minLength": 1 + }, + "rust": { + "description": "Passed to \"cargo build --target\" / \"cross build --target\". \nAlso used to locate the release tarball: \"runner--.tar.gz\".", + "markdownDescription": "# Rust target triple \nPassed to `cargo build --target` / `cross build --target`. \nAlso used to locate the release tarball: `runner--.tar.gz`.", + "type": "string", + "pattern": "^[a-z0-9_]+(-[a-z0-9_]+){2,3}$" + }, + "tier": { + "description": "1 = first-class (release-blocking), \n2 = best-effort, \n3 = experimental (failures don't block release).", + "markdownDescription": "# Support tier \n1 = first-class (release-blocking), \n2 = best-effort, \n3 = experimental (failures don't block release).", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + } + }, + "additionalProperties": false, + "dependentSchemas": { + "libc": { + "description": "libc is only meaningful on Linux. If declared, os must include linux.", + "required": [ + "os" + ], + "properties": { + "os": { + "type": "array", + "contains": { + "const": "linux" + } + } + } + } + }, + "allOf": [ + { + "if": { + "required": [ + "os" + ], + "properties": { + "os": { + "type": "array", + "contains": { + "const": "linux" + } + } + } + }, + "then": { + "description": "Linux targets must declare libc to disambiguate glibc and musl builds at install time.", + "required": [ + "libc" + ] + } + }, + { + "if": { + "required": [ + "experimental" + ], + "properties": { + "experimental": { + "const": true + } + } + }, + "then": { + "description": "Only tier-3 targets may be marked experimental.", + "properties": { + "tier": { + "const": 3 + } + } + } + } + ] + } + }, + "title": "runner npm targets matrix", + "description": "Build matrix for the runner-run façade and its per-platform optionalDependencies. Consumed by npm/scripts/build-packages.ts and the release workflow.", + "type": "object", + "required": [ + "binaries", + "facade", + "scope", + "targets" + ], + "properties": { + "$schema": { + "description": "Pointer to this schema for editor tooling.", + "examples": [ + "https://github.com/kjanat/runner/blob/master/npm/targets.schema.json", + "./targets.schema.json" + ], + "type": "string" + }, + "binaries": { + "description": "Binary names extracted from each release tarball and shipped in every platform sub-package's \"bin/\" directory. \nOn Windows, \".exe\" is appended automatically.", + "markdownDescription": "# Binary names \nBinary names extracted from each release tarball and shipped in every platform sub-package's `bin/` directory. \nOn Windows, `.exe` is appended automatically.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + } + }, + "facade": { + "description": "npm package name of the façade (the package users install). \nResolves to the correct platform sub-package via \"optionalDependencies\".", + "markdownDescription": "# Facade package name \nnpm `package` name of the façade (the package users install). \nResolves to the correct platform sub-package via `optionalDependencies`.", + "default": "runner-run", + "examples": [ + "runner-run" + ], + "type": "string", + "minLength": 1 + }, + "scope": { + "description": "npm scope under which platform sub-packages are published. \nEach sub-package is published as \"/\".", + "markdownDescription": "# npm scope \nScope under which platform sub-packages are published. \nEach sub-package is published as `/`.", + "default": "@runner-run", + "examples": [ + "@runner-run" + ], + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*$" + }, + "targets": { + "description": "Per-platform build targets. \nOne entry per published sub-package.", + "markdownDescription": "# Build targets \nPer-platform build targets. One entry per published sub-package.", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/target" + } + } + }, + "additionalProperties": false } diff --git a/package.json b/package.json index 211fdfe..b084c03 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ ], "scripts": { "fmt": "bunx dprint fmt", + "fmt:update": "bunx dprint config update --yes", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 253b8f6..f4f1cab 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" +channel = "stable" components = ["rustfmt", "clippy", "rust-analyzer"] profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..ec25a1d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = 2024 diff --git a/schemas/doctor.v1.example.json b/schemas/doctor.v1.example.json new file mode 100644 index 0000000..68e8a8a --- /dev/null +++ b/schemas/doctor.v1.example.json @@ -0,0 +1,234 @@ +{ + "schema_version": 1, + "root": "/home/kjanat/projects/runner", + "ecosystems": [ + "node", + "rust" + ], + "detected": { + "package_managers": [ + "bun", + "cargo" + ], + "task_runners": [ + "just" + ], + "node_version": null, + "current_node": "24.14.1", + "monorepo": true + }, + "overrides": { + "pm": null, + "pm_by_ecosystem": {}, + "runner": null, + "prefer_runners": [], + "fallback": "probe", + "on_mismatch": "warn", + "explain": false, + "no_warnings": false + }, + "signals": { + "node": { + "lockfile_pm": "bun", + "manifest_pm": { + "pm": "bun", + "source": "packageManager", + "version": "1.3.14", + "on_fail": "ignore" + }, + "path_probe": { + "bun": "/home/kjanat/.bun/bin/bun", + "npm": "/home/kjanat/.volta/bin/npm", + "pnpm": "/home/kjanat/.volta/bin/pnpm", + "yarn": "/home/kjanat/.volta/bin/yarn" + }, + "volta_shims": { + "npm": { + "resolved": "/home/kjanat/.volta/tools/image/npm/11.6.2/bin/npm" + }, + "pnpm": { + "resolved": "/home/kjanat/.volta/tools/image/packages/pnpm/bin/pnpm" + }, + "yarn": { + "resolved": "/home/kjanat/.volta/tools/image/yarn/1.22.22/bin/yarn" + } + } + } + }, + "decisions": { + "node_pm": { + "pm": "bun", + "via": "bun via package.json \"packageManager\"" + } + }, + "tasks": [ + { + "name": "fmt", + "source": "package.json" + }, + { + "name": "fmt:update", + "source": "package.json" + }, + { + "name": "typecheck", + "source": "package.json" + }, + { + "name": "build-packages", + "source": "justfile" + }, + { + "name": "default", + "source": "justfile" + }, + { + "name": "gen-schema", + "source": "justfile", + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" + }, + { + "name": "ls", + "source": "justfile" + }, + { + "name": "run", + "source": "justfile" + }, + { + "name": "runner", + "source": "justfile" + }, + { + "name": "test-release", + "source": "justfile", + "description": "Build release bin and verify the facade shims spawn the native binary." + }, + { + "name": "b", + "source": "cargo", + "alias_of": "build" + }, + { + "name": "bb", + "source": "cargo", + "alias_of": "build --bin run --bin runner" + }, + { + "name": "bbr", + "source": "cargo", + "alias_of": "build --bin run --bin runner --release" + }, + { + "name": "bin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run" + }, + { + "name": "bin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "c", + "source": "cargo", + "alias_of": "check" + }, + { + "name": "cl", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features" + }, + { + "name": "comp", + "source": "cargo", + "alias_of": "run --quiet --bin runner -- completions" + }, + { + "name": "d", + "source": "cargo", + "alias_of": "doc" + }, + { + "name": "f", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "format", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "i", + "source": "cargo", + "alias_of": "install --path ." + }, + { + "name": "l", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "lint", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "man", + "source": "cargo", + "alias_of": "run --quiet --features man -- man" + }, + { + "name": "meta", + "source": "cargo", + "alias_of": "metadata --format-version 1" + }, + { + "name": "r", + "source": "cargo", + "alias_of": "run" + }, + { + "name": "rbin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run --release" + }, + { + "name": "rbin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner --release" + }, + { + "name": "rm", + "source": "cargo", + "alias_of": "remove" + }, + { + "name": "rq", + "source": "cargo", + "alias_of": "run --quiet" + }, + { + "name": "rr", + "source": "cargo", + "alias_of": "run --release" + }, + { + "name": "runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "schema", + "source": "cargo", + "alias_of": "run --quiet --features schema -- schema" + }, + { + "name": "t", + "source": "cargo", + "alias_of": "test" + } + ], + "warnings": [] +} diff --git a/schemas/doctor.v1.schema.json b/schemas/doctor.v1.schema.json new file mode 100644 index 0000000..72d98f1 --- /dev/null +++ b/schemas/doctor.v1.schema.json @@ -0,0 +1,471 @@ +{ + "$id": "https://kjanat.github.io/schemas/doctor.v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Decisions": { + "description": "Resolver verdict surface. Mirrors the resolver's `Result` so\nconsumers can branch on the variant before reading the inner shape.", + "type": "object", + "required": [ + "node_pm" + ], + "properties": { + "node_pm": { + "$ref": "#/$defs/NodePmDecision", + "description": "Node script-dispatch PM decision, or an error message when the\nresolver bailed." + } + } + }, + "Detected": { + "description": "Detection results — what the file scan found, before any resolver\npolicy was applied.", + "type": "object", + "required": [ + "current_node", + "monorepo", + "node_version", + "package_managers", + "task_runners" + ], + "properties": { + "current_node": { + "description": "`node --version` output, when the binary is on PATH.", + "type": [ + "null", + "string" + ] + }, + "monorepo": { + "description": "Whether the project looks like a monorepo (workspace globs).", + "type": "boolean" + }, + "node_version": { + "description": "`.nvmrc` / `.node-version` / `engines.node` declaration.", + "anyOf": [ + { + "$ref": "#/$defs/NodeVersionInfo" + }, + { + "type": "null" + } + ] + }, + "package_managers": { + "description": "Detected package managers, in detection-priority order.", + "type": "array", + "items": { + "type": "string" + } + }, + "task_runners": { + "description": "Detected task runners.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ManifestPm": { + "description": "Manifest-level PM declaration plus the field it came from.", + "type": "object", + "required": [ + "on_fail", + "pm", + "source", + "version" + ], + "properties": { + "on_fail": { + "description": "Effective `onFail` policy (`\"ignore\"`, `\"warn\"`, `\"error\"`).", + "type": "string" + }, + "pm": { + "description": "Declared PM label.", + "type": "string" + }, + "source": { + "description": "Either `\"packageManager\"` or `\"devEngines.packageManager\"`.", + "type": "string" + }, + "version": { + "description": "Version constraint as written, if present.", + "type": [ + "null", + "string" + ] + } + } + }, + "NodePmDecision": { + "description": "Either a resolved Node PM or the diagnostic string for the failure\nthat prevented one. Untagged so consumers can probe via \"is the\n`pm` field present?\".", + "anyOf": [ + { + "description": "Successful resolution.", + "type": "object", + "required": [ + "pm", + "via" + ], + "properties": { + "pm": { + "description": "The chosen PM label.", + "type": "string" + }, + "via": { + "description": "Human-readable `via` line — the same string `--explain` prints.", + "type": "string" + } + } + }, + { + "description": "Resolver bailed; carries the rendered error message.", + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "description": "One-line error description from `ResolveError::Display`.", + "type": "string" + } + } + } + ] + }, + "NodeSignals": { + "description": "Node-ecosystem detection signals: lockfile, manifest, PATH probe.", + "type": "object", + "required": [ + "lockfile_pm", + "manifest_pm", + "path_probe" + ], + "properties": { + "lockfile_pm": { + "description": "PM inferred from the highest-priority lockfile, if any.", + "type": [ + "null", + "string" + ] + }, + "manifest_pm": { + "description": "Manifest declaration (legacy `packageManager` or `devEngines`).", + "anyOf": [ + { + "$ref": "#/$defs/ManifestPm" + }, + { + "type": "null" + } + ] + }, + "path_probe": { + "description": "`bun`/`pnpm`/`yarn`/`npm` -> absolute path on `$PATH` (or null).", + "type": "object", + "additionalProperties": { + "type": [ + "null", + "string" + ] + } + }, + "volta_shims": { + "description": "PATH-probe hits identified as Volta shims, keyed like\n[`Self::path_probe`]. Additive field (no schema bump): absent on\nhosts without Volta and on surfaces that skip shim resolution.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/VoltaShimInfo" + } + } + } + }, + "NodeVersionInfo": { + "description": "Node version declaration plus the file it came from.", + "type": "object", + "required": [ + "expected", + "source" + ], + "properties": { + "expected": { + "description": "Version string as written (e.g. `\"20.11.0\"`, `\">=18\"`).", + "type": "string" + }, + "source": { + "description": "Source file that declared the version (e.g. `\".nvmrc\"`).", + "type": "string" + } + } + }, + "OverridesView": { + "description": "Materialised override stack — the inputs that fed into resolver\ndecisions.", + "type": "object", + "required": [ + "explain", + "fallback", + "no_warnings", + "on_mismatch", + "pm", + "pm_by_ecosystem", + "prefer_runners", + "runner" + ], + "properties": { + "explain": { + "description": "Whether the explain trace is on.", + "type": "boolean" + }, + "fallback": { + "description": "Active `FallbackPolicy` label.", + "type": "string" + }, + "no_warnings": { + "description": "Whether warnings are suppressed.", + "type": "boolean" + }, + "on_mismatch": { + "description": "Active `MismatchPolicy` label.", + "type": "string" + }, + "pm": { + "description": "Cross-ecosystem PM override from `--pm` / `RUNNER_PM`.", + "anyOf": [ + { + "$ref": "#/$defs/PmOverrideInfo" + }, + { + "type": "null" + } + ] + }, + "pm_by_ecosystem": { + "description": "Per-ecosystem PM overrides from `runner.toml [pm].`.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/PmOverrideInfo" + } + }, + "prefer_runners": { + "description": "Ranked preference list from `[task_runner].prefer`.", + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "description": "`--runner` / `RUNNER_RUNNER` override.", + "anyOf": [ + { + "$ref": "#/$defs/RunnerOverrideInfo" + }, + { + "type": "null" + } + ] + } + } + }, + "PmOverrideInfo": { + "description": "PM override + provenance.", + "type": "object", + "required": [ + "origin", + "pm" + ], + "properties": { + "origin": { + "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", + "type": "string" + }, + "pm": { + "description": "The chosen PM label.", + "type": "string" + } + } + }, + "RunnerOverrideInfo": { + "description": "Task-runner override + provenance.", + "type": "object", + "required": [ + "origin", + "runner" + ], + "properties": { + "origin": { + "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", + "type": "string" + }, + "runner": { + "description": "The chosen runner label.", + "type": "string" + } + } + }, + "Signals": { + "description": "Per-ecosystem signals — what the resolver had to work with.", + "type": "object", + "required": [ + "node" + ], + "properties": { + "node": { + "$ref": "#/$defs/NodeSignals", + "description": "Node-ecosystem signals. The schema is intentionally\nnode-flat today; other ecosystems get peer fields as their\nresolver paths land." + } + } + }, + "TaskInfo": { + "description": "Task entry projected into the JSON shape.", + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "alias_of": { + "description": "When the task is an alias, the target it resolves to.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "Human-readable description, if any.", + "type": [ + "null", + "string" + ] + }, + "name": { + "description": "Task name as it appears in the config.", + "type": "string" + }, + "passthrough_to": { + "description": "When the task's body is a thin wrapper for another runner.", + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "Makefile", + "justfile", + "Taskfile", + "turbo.json", + "deno.json", + "cargo", + "go", + "bacon.toml", + "mise.toml", + "pyproject.toml" + ] + }, + "VoltaShimInfo": { + "description": "What `volta which` said about one shimmed tool.", + "type": "object", + "required": [ + "resolved" + ], + "properties": { + "resolved": { + "description": "Real provisioned binary behind the shim; `null` when Volta has\nno version of the tool (\"not provisioned\"). Shims Volta could\nnot classify at all are omitted from the map instead of guessed.", + "type": [ + "null", + "string" + ] + } + } + }, + "WarningInfo": { + "description": "Warning projected into the JSON shape. The `source`/`detail` split\nis kept stable from the pre-A4 flat-struct days so existing\nconsumers (the `doctor` test suite, ad-hoc `jq` queries) keep\nworking.", + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "description": "Human-readable detail.", + "type": "string" + }, + "source": { + "description": "Subsystem the warning came from (e.g. `\"package.json\"`).", + "type": "string" + } + } + } + }, + "title": "runner doctor --json --schema-version 1", + "description": "JSON schema for the legacy v1 `runner doctor --json` document. v1 uses filename-style task source labels.", + "type": "object", + "required": [ + "decisions", + "detected", + "ecosystems", + "overrides", + "root", + "schema_version", + "signals", + "warnings" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "decisions": { + "$ref": "#/$defs/Decisions", + "description": "Resolver verdict (or first-class error if the chain bailed)." + }, + "detected": { + "$ref": "#/$defs/Detected", + "description": "Raw, type-deduplicated detection results: PMs, runners, Node\nversion, monorepo flag. Stable across resolver behavior tweaks." + }, + "ecosystems": { + "description": "Detected ecosystems, in the order their package managers were\nfound by [`crate::detect`].", + "type": "array", + "items": { + "type": "string" + } + }, + "overrides": { + "$ref": "#/$defs/OverridesView", + "description": "Effective override stack — CLI, env, and config bundled." + }, + "root": { + "description": "Absolute path of the project root the report describes.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 1, + "minimum": 0, + "format": "uint32" + }, + "signals": { + "$ref": "#/$defs/Signals", + "description": "Per-ecosystem detection signals: lockfile pick, manifest\ndeclaration, PATH probe results." + }, + "tasks": { + "description": "Full task list. Subcommands that don't care omit this via\nprojection.", + "type": "array", + "items": { + "$ref": "#/$defs/TaskInfo" + } + }, + "warnings": { + "description": "Diagnostic warnings from both detection (`ctx.warnings`) and\nthe resolver (`ResolvedPm.warnings`), flattened.", + "type": "array", + "items": { + "$ref": "#/$defs/WarningInfo" + } + } + } +} diff --git a/schemas/doctor.v2.example.json b/schemas/doctor.v2.example.json new file mode 100644 index 0000000..0c283e8 --- /dev/null +++ b/schemas/doctor.v2.example.json @@ -0,0 +1,234 @@ +{ + "schema_version": 2, + "root": "/home/kjanat/projects/runner", + "ecosystems": [ + "node", + "rust" + ], + "detected": { + "package_managers": [ + "bun", + "cargo" + ], + "task_runners": [ + "just" + ], + "node_version": null, + "current_node": "24.14.1", + "monorepo": true + }, + "overrides": { + "pm": null, + "pm_by_ecosystem": {}, + "runner": null, + "prefer_runners": [], + "fallback": "probe", + "on_mismatch": "warn", + "explain": false, + "no_warnings": false + }, + "signals": { + "node": { + "lockfile_pm": "bun", + "manifest_pm": { + "pm": "bun", + "source": "packageManager", + "version": "1.3.14", + "on_fail": "ignore" + }, + "path_probe": { + "bun": "/home/kjanat/.bun/bin/bun", + "npm": "/home/kjanat/.volta/bin/npm", + "pnpm": "/home/kjanat/.volta/bin/pnpm", + "yarn": "/home/kjanat/.volta/bin/yarn" + }, + "volta_shims": { + "npm": { + "resolved": "/home/kjanat/.volta/tools/image/npm/11.6.2/bin/npm" + }, + "pnpm": { + "resolved": "/home/kjanat/.volta/tools/image/packages/pnpm/bin/pnpm" + }, + "yarn": { + "resolved": "/home/kjanat/.volta/tools/image/yarn/1.22.22/bin/yarn" + } + } + } + }, + "decisions": { + "node_pm": { + "pm": "bun", + "via": "bun via package.json \"packageManager\"" + } + }, + "tasks": [ + { + "name": "fmt", + "source": "package.json" + }, + { + "name": "fmt:update", + "source": "package.json" + }, + { + "name": "typecheck", + "source": "package.json" + }, + { + "name": "build-packages", + "source": "just" + }, + { + "name": "default", + "source": "just" + }, + { + "name": "gen-schema", + "source": "just", + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" + }, + { + "name": "ls", + "source": "just" + }, + { + "name": "run", + "source": "just" + }, + { + "name": "runner", + "source": "just" + }, + { + "name": "test-release", + "source": "just", + "description": "Build release bin and verify the facade shims spawn the native binary." + }, + { + "name": "b", + "source": "cargo", + "alias_of": "build" + }, + { + "name": "bb", + "source": "cargo", + "alias_of": "build --bin run --bin runner" + }, + { + "name": "bbr", + "source": "cargo", + "alias_of": "build --bin run --bin runner --release" + }, + { + "name": "bin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run" + }, + { + "name": "bin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "c", + "source": "cargo", + "alias_of": "check" + }, + { + "name": "cl", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features" + }, + { + "name": "comp", + "source": "cargo", + "alias_of": "run --quiet --bin runner -- completions" + }, + { + "name": "d", + "source": "cargo", + "alias_of": "doc" + }, + { + "name": "f", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "format", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "i", + "source": "cargo", + "alias_of": "install --path ." + }, + { + "name": "l", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "lint", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "man", + "source": "cargo", + "alias_of": "run --quiet --features man -- man" + }, + { + "name": "meta", + "source": "cargo", + "alias_of": "metadata --format-version 1" + }, + { + "name": "r", + "source": "cargo", + "alias_of": "run" + }, + { + "name": "rbin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run --release" + }, + { + "name": "rbin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner --release" + }, + { + "name": "rm", + "source": "cargo", + "alias_of": "remove" + }, + { + "name": "rq", + "source": "cargo", + "alias_of": "run --quiet" + }, + { + "name": "rr", + "source": "cargo", + "alias_of": "run --release" + }, + { + "name": "runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "schema", + "source": "cargo", + "alias_of": "run --quiet --features schema -- schema" + }, + { + "name": "t", + "source": "cargo", + "alias_of": "test" + } + ], + "warnings": [] +} diff --git a/schemas/doctor.v2.schema.json b/schemas/doctor.v2.schema.json new file mode 100644 index 0000000..e9dc8f1 --- /dev/null +++ b/schemas/doctor.v2.schema.json @@ -0,0 +1,471 @@ +{ + "$id": "https://kjanat.github.io/schemas/doctor.v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Decisions": { + "description": "Resolver verdict surface. Mirrors the resolver's `Result` so\nconsumers can branch on the variant before reading the inner shape.", + "type": "object", + "required": [ + "node_pm" + ], + "properties": { + "node_pm": { + "$ref": "#/$defs/NodePmDecision", + "description": "Node script-dispatch PM decision, or an error message when the\nresolver bailed." + } + } + }, + "Detected": { + "description": "Detection results — what the file scan found, before any resolver\npolicy was applied.", + "type": "object", + "required": [ + "current_node", + "monorepo", + "node_version", + "package_managers", + "task_runners" + ], + "properties": { + "current_node": { + "description": "`node --version` output, when the binary is on PATH.", + "type": [ + "null", + "string" + ] + }, + "monorepo": { + "description": "Whether the project looks like a monorepo (workspace globs).", + "type": "boolean" + }, + "node_version": { + "description": "`.nvmrc` / `.node-version` / `engines.node` declaration.", + "anyOf": [ + { + "$ref": "#/$defs/NodeVersionInfo" + }, + { + "type": "null" + } + ] + }, + "package_managers": { + "description": "Detected package managers, in detection-priority order.", + "type": "array", + "items": { + "type": "string" + } + }, + "task_runners": { + "description": "Detected task runners.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ManifestPm": { + "description": "Manifest-level PM declaration plus the field it came from.", + "type": "object", + "required": [ + "on_fail", + "pm", + "source", + "version" + ], + "properties": { + "on_fail": { + "description": "Effective `onFail` policy (`\"ignore\"`, `\"warn\"`, `\"error\"`).", + "type": "string" + }, + "pm": { + "description": "Declared PM label.", + "type": "string" + }, + "source": { + "description": "Either `\"packageManager\"` or `\"devEngines.packageManager\"`.", + "type": "string" + }, + "version": { + "description": "Version constraint as written, if present.", + "type": [ + "null", + "string" + ] + } + } + }, + "NodePmDecision": { + "description": "Either a resolved Node PM or the diagnostic string for the failure\nthat prevented one. Untagged so consumers can probe via \"is the\n`pm` field present?\".", + "anyOf": [ + { + "description": "Successful resolution.", + "type": "object", + "required": [ + "pm", + "via" + ], + "properties": { + "pm": { + "description": "The chosen PM label.", + "type": "string" + }, + "via": { + "description": "Human-readable `via` line — the same string `--explain` prints.", + "type": "string" + } + } + }, + { + "description": "Resolver bailed; carries the rendered error message.", + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "description": "One-line error description from `ResolveError::Display`.", + "type": "string" + } + } + } + ] + }, + "NodeSignals": { + "description": "Node-ecosystem detection signals: lockfile, manifest, PATH probe.", + "type": "object", + "required": [ + "lockfile_pm", + "manifest_pm", + "path_probe" + ], + "properties": { + "lockfile_pm": { + "description": "PM inferred from the highest-priority lockfile, if any.", + "type": [ + "null", + "string" + ] + }, + "manifest_pm": { + "description": "Manifest declaration (legacy `packageManager` or `devEngines`).", + "anyOf": [ + { + "$ref": "#/$defs/ManifestPm" + }, + { + "type": "null" + } + ] + }, + "path_probe": { + "description": "`bun`/`pnpm`/`yarn`/`npm` -> absolute path on `$PATH` (or null).", + "type": "object", + "additionalProperties": { + "type": [ + "null", + "string" + ] + } + }, + "volta_shims": { + "description": "PATH-probe hits identified as Volta shims, keyed like\n[`Self::path_probe`]. Additive field (no schema bump): absent on\nhosts without Volta and on surfaces that skip shim resolution.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/VoltaShimInfo" + } + } + } + }, + "NodeVersionInfo": { + "description": "Node version declaration plus the file it came from.", + "type": "object", + "required": [ + "expected", + "source" + ], + "properties": { + "expected": { + "description": "Version string as written (e.g. `\"20.11.0\"`, `\">=18\"`).", + "type": "string" + }, + "source": { + "description": "Source file that declared the version (e.g. `\".nvmrc\"`).", + "type": "string" + } + } + }, + "OverridesView": { + "description": "Materialised override stack — the inputs that fed into resolver\ndecisions.", + "type": "object", + "required": [ + "explain", + "fallback", + "no_warnings", + "on_mismatch", + "pm", + "pm_by_ecosystem", + "prefer_runners", + "runner" + ], + "properties": { + "explain": { + "description": "Whether the explain trace is on.", + "type": "boolean" + }, + "fallback": { + "description": "Active `FallbackPolicy` label.", + "type": "string" + }, + "no_warnings": { + "description": "Whether warnings are suppressed.", + "type": "boolean" + }, + "on_mismatch": { + "description": "Active `MismatchPolicy` label.", + "type": "string" + }, + "pm": { + "description": "Cross-ecosystem PM override from `--pm` / `RUNNER_PM`.", + "anyOf": [ + { + "$ref": "#/$defs/PmOverrideInfo" + }, + { + "type": "null" + } + ] + }, + "pm_by_ecosystem": { + "description": "Per-ecosystem PM overrides from `runner.toml [pm].`.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/PmOverrideInfo" + } + }, + "prefer_runners": { + "description": "Ranked preference list from `[task_runner].prefer`.", + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "description": "`--runner` / `RUNNER_RUNNER` override.", + "anyOf": [ + { + "$ref": "#/$defs/RunnerOverrideInfo" + }, + { + "type": "null" + } + ] + } + } + }, + "PmOverrideInfo": { + "description": "PM override + provenance.", + "type": "object", + "required": [ + "origin", + "pm" + ], + "properties": { + "origin": { + "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", + "type": "string" + }, + "pm": { + "description": "The chosen PM label.", + "type": "string" + } + } + }, + "RunnerOverrideInfo": { + "description": "Task-runner override + provenance.", + "type": "object", + "required": [ + "origin", + "runner" + ], + "properties": { + "origin": { + "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", + "type": "string" + }, + "runner": { + "description": "The chosen runner label.", + "type": "string" + } + } + }, + "Signals": { + "description": "Per-ecosystem signals — what the resolver had to work with.", + "type": "object", + "required": [ + "node" + ], + "properties": { + "node": { + "$ref": "#/$defs/NodeSignals", + "description": "Node-ecosystem signals. The schema is intentionally\nnode-flat today; other ecosystems get peer fields as their\nresolver paths land." + } + } + }, + "TaskInfo": { + "description": "Task entry projected into the JSON shape.", + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "alias_of": { + "description": "When the task is an alias, the target it resolves to.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "Human-readable description, if any.", + "type": [ + "null", + "string" + ] + }, + "name": { + "description": "Task name as it appears in the config.", + "type": "string" + }, + "passthrough_to": { + "description": "When the task's body is a thin wrapper for another runner.", + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + }, + "VoltaShimInfo": { + "description": "What `volta which` said about one shimmed tool.", + "type": "object", + "required": [ + "resolved" + ], + "properties": { + "resolved": { + "description": "Real provisioned binary behind the shim; `null` when Volta has\nno version of the tool (\"not provisioned\"). Shims Volta could\nnot classify at all are omitted from the map instead of guessed.", + "type": [ + "null", + "string" + ] + } + } + }, + "WarningInfo": { + "description": "Warning projected into the JSON shape. The `source`/`detail` split\nis kept stable from the pre-A4 flat-struct days so existing\nconsumers (the `doctor` test suite, ad-hoc `jq` queries) keep\nworking.", + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "description": "Human-readable detail.", + "type": "string" + }, + "source": { + "description": "Subsystem the warning came from (e.g. `\"package.json\"`).", + "type": "string" + } + } + } + }, + "title": "runner doctor --json --schema-version 2", + "description": "JSON schema for the current v2 `runner doctor --json` document. v2 uses tool-name task source labels.", + "type": "object", + "required": [ + "decisions", + "detected", + "ecosystems", + "overrides", + "root", + "schema_version", + "signals", + "warnings" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "decisions": { + "$ref": "#/$defs/Decisions", + "description": "Resolver verdict (or first-class error if the chain bailed)." + }, + "detected": { + "$ref": "#/$defs/Detected", + "description": "Raw, type-deduplicated detection results: PMs, runners, Node\nversion, monorepo flag. Stable across resolver behavior tweaks." + }, + "ecosystems": { + "description": "Detected ecosystems, in the order their package managers were\nfound by [`crate::detect`].", + "type": "array", + "items": { + "type": "string" + } + }, + "overrides": { + "$ref": "#/$defs/OverridesView", + "description": "Effective override stack — CLI, env, and config bundled." + }, + "root": { + "description": "Absolute path of the project root the report describes.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 2, + "minimum": 0, + "format": "uint32" + }, + "signals": { + "$ref": "#/$defs/Signals", + "description": "Per-ecosystem detection signals: lockfile pick, manifest\ndeclaration, PATH probe results." + }, + "tasks": { + "description": "Full task list. Subcommands that don't care omit this via\nprojection.", + "type": "array", + "items": { + "$ref": "#/$defs/TaskInfo" + } + }, + "warnings": { + "description": "Diagnostic warnings from both detection (`ctx.warnings`) and\nthe resolver (`ResolvedPm.warnings`), flattened.", + "type": "array", + "items": { + "$ref": "#/$defs/WarningInfo" + } + } + } +} diff --git a/schemas/doctor.v3-draft.schema.json b/schemas/doctor.v3-draft.schema.json new file mode 100644 index 0000000..058d8ef --- /dev/null +++ b/schemas/doctor.v3-draft.schema.json @@ -0,0 +1,880 @@ +{ + "$id": "https://kjanat.github.io/schemas/doctor.v3.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "absolute_path": { + "description": "Absolute path as emitted by the host platform. Kept intentionally permissive for POSIX, Windows, and virtual paths.", + "type": "string", + "minLength": 1 + }, + "conflict": { + "type": "object", + "required": [ + "kind", + "reason", + "selected", + "selector", + "severity", + "shadowed" + ], + "properties": { + "kind": { + "$ref": "#/$defs/non_empty_string" + }, + "reason": { + "$ref": "#/$defs/non_empty_string" + }, + "selected": { + "$ref": "#/$defs/fqn" + }, + "selector": { + "$ref": "#/$defs/non_empty_string" + }, + "severity": { + "$ref": "#/$defs/severity" + }, + "shadowed": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/fqn" + } + } + }, + "additionalProperties": false + }, + "dependency": { + "type": "object", + "required": [ + "constraints", + "kind", + "name", + "required", + "resolved" + ], + "properties": { + "constraints": { + "$ref": "#/$defs/version_constraints" + }, + "kind": { + "$ref": "#/$defs/dependency_kind" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "required": { + "type": "boolean" + }, + "resolved": { + "$ref": "#/$defs/dependency_resolution" + } + }, + "additionalProperties": false + }, + "dependency_found": { + "type": "object", + "required": [ + "found", + "satisfies" + ], + "properties": { + "found": { + "type": "boolean", + "const": true + }, + "path": { + "$ref": "#/$defs/nullable_string" + }, + "satisfies": { + "type": "boolean" + }, + "version": { + "$ref": "#/$defs/nullable_string" + }, + "via": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "dependency_kind": { + "type": "string", + "enum": [ + "binary", + "package-binary", + "runtime", + "package-manager", + "task-runner" + ] + }, + "dependency_missing": { + "type": "object", + "required": [ + "found" + ], + "properties": { + "found": { + "type": "boolean", + "const": false + }, + "via": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "dependency_resolution": { + "oneOf": [ + { + "$ref": "#/$defs/dependency_found" + }, + { + "$ref": "#/$defs/dependency_missing" + } + ] + }, + "diagnostic": { + "type": "object", + "required": [ + "code", + "message", + "severity" + ], + "properties": { + "code": { + "$ref": "#/$defs/non_empty_string" + }, + "details": { + "$ref": "#/$defs/json_value" + }, + "message": { + "$ref": "#/$defs/non_empty_string" + }, + "severity": { + "$ref": "#/$defs/severity" + }, + "source": { + "$ref": "#/$defs/nullable_string" + }, + "task": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/fqn" + } + ] + } + }, + "additionalProperties": false + }, + "ecosystem": { + "type": "object", + "required": [ + "decision", + "name", + "root", + "selected_package_manager", + "signals" + ], + "properties": { + "decision": { + "$ref": "#/$defs/ecosystem_decision" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "root": { + "$ref": "#/$defs/absolute_path" + }, + "selected_package_manager": { + "$ref": "#/$defs/nullable_string" + }, + "signals": { + "$ref": "#/$defs/signals" + } + }, + "additionalProperties": false + }, + "ecosystem_decision": { + "type": "object", + "required": [ + "confidence", + "reason", + "selected" + ], + "properties": { + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "none" + ] + }, + "reason": { + "$ref": "#/$defs/non_empty_string" + }, + "selected": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "environment": { + "type": "object", + "required": [ + "arch", + "os", + "path_entries", + "shell" + ], + "properties": { + "arch": { + "$ref": "#/$defs/non_empty_string" + }, + "os": { + "$ref": "#/$defs/non_empty_string" + }, + "path_entries": { + "type": "array", + "items": { + "$ref": "#/$defs/absolute_path" + } + }, + "shell": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "fqn": { + "description": "Fully qualified task name: ::. The task-name segment may itself contain colons.", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*:[A-Za-z][A-Za-z0-9._-]*:.+$" + }, + "invocation": { + "type": "object", + "required": [ + "argv", + "cwd", + "started_at" + ], + "properties": { + "argv": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/non_empty_string" + } + }, + "cwd": { + "$ref": "#/$defs/absolute_path" + }, + "started_at": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "json_value": { + "description": "Arbitrary JSON value.", + "oneOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/json_value" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/json_value" + } + } + ] + }, + "non_empty_string": { + "type": "string", + "minLength": 1 + }, + "nullable_string": { + "type": [ + "null", + "string" + ] + }, + "overrides": { + "type": "object", + "required": [ + "explain", + "fallback", + "no_warnings", + "on_mismatch", + "pm", + "pm_by_ecosystem", + "prefer_runners", + "runner" + ], + "properties": { + "explain": { + "type": "boolean" + }, + "fallback": { + "type": "string", + "enum": [ + "probe", + "npm", + "error" + ] + }, + "no_warnings": { + "type": "boolean" + }, + "on_mismatch": { + "type": "string", + "enum": [ + "warn", + "error", + "ignore" + ] + }, + "pm": { + "$ref": "#/$defs/nullable_string" + }, + "pm_by_ecosystem": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/nullable_string" + } + }, + "prefer_runners": { + "type": "array", + "items": { + "$ref": "#/$defs/non_empty_string" + } + }, + "runner": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "package_identity": { + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "name": { + "$ref": "#/$defs/nullable_string" + }, + "source": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "project": { + "type": "object", + "required": [ + "monorepo", + "root", + "root_source", + "workspace" + ], + "properties": { + "monorepo": { + "type": "boolean" + }, + "root": { + "$ref": "#/$defs/absolute_path" + }, + "root_source": { + "$ref": "#/$defs/absolute_path" + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/workspace" + } + ] + } + }, + "additionalProperties": false + }, + "relative_path": { + "type": "string", + "minLength": 1 + }, + "resolution": { + "type": "object", + "required": [ + "fqn_policy", + "precedence", + "short_name_policy" + ], + "properties": { + "fqn_policy": { + "type": "string", + "enum": [ + "exact-only" + ] + }, + "precedence": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/non_empty_string" + } + }, + "short_name_policy": { + "type": "string", + "enum": [ + "deterministic-precedence", + "ambiguous-error", + "first-match" + ] + } + }, + "additionalProperties": false + }, + "runner": { + "type": "object", + "required": [ + "binary", + "name", + "schema_versions", + "version" + ], + "properties": { + "binary": { + "$ref": "#/$defs/absolute_path" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "schema_versions": { + "type": "object", + "required": [ + "doctor", + "list", + "why" + ], + "properties": { + "doctor": { + "type": "integer", + "const": 3 + }, + "list": { + "type": "integer", + "minimum": 1 + }, + "why": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false + }, + "version": { + "$ref": "#/$defs/non_empty_string" + } + }, + "additionalProperties": false + }, + "severity": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "signals": { + "description": "Detection evidence. Deliberately flexible because each ecosystem has different signal types.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/json_value" + } + }, + "source": { + "type": "object", + "required": [ + "exists", + "id", + "kind", + "path", + "relpath", + "scope", + "task_pointer" + ], + "properties": { + "exists": { + "type": "boolean" + }, + "id": { + "$ref": "#/$defs/source_id" + }, + "kind": { + "$ref": "#/$defs/source_kind" + }, + "package": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/package_identity" + } + ] + }, + "path": { + "$ref": "#/$defs/absolute_path" + }, + "relpath": { + "$ref": "#/$defs/relative_path" + }, + "scope": { + "$ref": "#/$defs/source_scope" + }, + "task_pointer": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "source_id": { + "type": "string", + "pattern": "^src:[A-Za-z0-9._:-]+$" + }, + "source_kind": { + "description": "Known examples: package-json, cargo-config, cargo-toml, justfile, makefile, taskfile, deno-json, pyproject-toml, mise-toml, bacon-toml.", + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9._-]*$" + }, + "source_scope": { + "description": "Stable project-root-relative scope. Examples: root, site, crates.runner, examples.basic.", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "task": { + "type": "object", + "required": [ + "aliases", + "cwd", + "definition", + "dependencies", + "description", + "fqn", + "name", + "resolved", + "source", + "source_pointer" + ], + "properties": { + "aliases": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/non_empty_string" + } + }, + "cwd": { + "$ref": "#/$defs/absolute_path" + }, + "definition": { + "$ref": "#/$defs/nullable_string" + }, + "dependencies": { + "type": "array", + "items": { + "$ref": "#/$defs/dependency" + } + }, + "description": { + "$ref": "#/$defs/nullable_string" + }, + "fqn": { + "$ref": "#/$defs/fqn" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "resolved": { + "$ref": "#/$defs/non_empty_string" + }, + "source": { + "$ref": "#/$defs/absolute_path" + }, + "source_pointer": { + "$ref": "#/$defs/nullable_string" + }, + "synthetic": { + "default": false, + "type": "boolean" + }, + "synthetic_reason": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "tool": { + "type": "object", + "required": [ + "id", + "kind", + "name", + "probe", + "required" + ], + "properties": { + "id": { + "$ref": "#/$defs/tool_id" + }, + "kind": { + "$ref": "#/$defs/dependency_kind" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "probe": { + "$ref": "#/$defs/tool_probe" + }, + "required": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "tool_id": { + "type": "string", + "pattern": "^tool:[A-Za-z0-9._:-]+$" + }, + "tool_probe": { + "oneOf": [ + { + "$ref": "#/$defs/tool_probe_found" + }, + { + "$ref": "#/$defs/tool_probe_missing" + }, + { + "$ref": "#/$defs/tool_probe_error" + } + ] + }, + "tool_probe_error": { + "type": "object", + "required": [ + "message", + "status" + ], + "properties": { + "message": { + "$ref": "#/$defs/non_empty_string" + }, + "status": { + "type": "string", + "const": "error" + } + }, + "additionalProperties": false + }, + "tool_probe_found": { + "type": "object", + "required": [ + "path", + "status", + "version" + ], + "properties": { + "path": { + "$ref": "#/$defs/absolute_path" + }, + "status": { + "type": "string", + "const": "found" + }, + "version": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "tool_probe_missing": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "const": "missing" + } + }, + "additionalProperties": false + }, + "version_constraints": { + "type": "object", + "required": [ + "max_version", + "min_version", + "version" + ], + "properties": { + "max_version": { + "$ref": "#/$defs/nullable_string" + }, + "min_version": { + "$ref": "#/$defs/nullable_string" + }, + "version": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "workspace": { + "type": "object", + "required": [ + "kind", + "root", + "source" + ], + "properties": { + "kind": { + "$ref": "#/$defs/non_empty_string" + }, + "root": { + "$ref": "#/$defs/absolute_path" + }, + "source": { + "$ref": "#/$defs/absolute_path" + } + }, + "additionalProperties": false + } + }, + "title": "runner doctor --json --schema-version 3", + "description": "JSON schema for the emitted `runner doctor --json` document.", + "type": "object", + "required": [ + "$schema", + "conflicts", + "diagnostics", + "ecosystems", + "environment", + "invocation", + "kind", + "overrides", + "project", + "resolution", + "runner", + "schema_version", + "sources", + "tasks", + "tools" + ], + "properties": { + "$schema": { + "description": "Schema URI/reference for this document.", + "examples": [ + "https://kjanat.github.io/schemas/doctor.v3.schema.json", + "./doctor.v3.schema.json" + ], + "type": "string", + "format": "uri-reference" + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/$defs/conflict" + } + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/diagnostic" + } + }, + "ecosystems": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/ecosystem" + } + }, + "environment": { + "$ref": "#/$defs/environment" + }, + "invocation": { + "$ref": "#/$defs/invocation" + }, + "kind": { + "type": "string", + "const": "runner.doctor" + }, + "overrides": { + "$ref": "#/$defs/overrides" + }, + "project": { + "$ref": "#/$defs/project" + }, + "resolution": { + "$ref": "#/$defs/resolution" + }, + "runner": { + "$ref": "#/$defs/runner" + }, + "schema_version": { + "type": "integer", + "const": 3 + }, + "sources": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/source" + } + }, + "tasks": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/task" + } + }, + "tools": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/tool" + } + } + }, + "additionalProperties": false +} diff --git a/schemas/list.v1.example.json b/schemas/list.v1.example.json new file mode 100644 index 0000000..7b9475e --- /dev/null +++ b/schemas/list.v1.example.json @@ -0,0 +1,173 @@ +{ + "schema_version": 1, + "root": "/home/kjanat/projects/runner", + "tasks": [ + { + "name": "fmt", + "source": "package.json" + }, + { + "name": "fmt:update", + "source": "package.json" + }, + { + "name": "typecheck", + "source": "package.json" + }, + { + "name": "build-packages", + "source": "justfile" + }, + { + "name": "default", + "source": "justfile" + }, + { + "name": "gen-schema", + "source": "justfile", + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" + }, + { + "name": "ls", + "source": "justfile" + }, + { + "name": "run", + "source": "justfile" + }, + { + "name": "runner", + "source": "justfile" + }, + { + "name": "test-release", + "source": "justfile", + "description": "Build release bin and verify the facade shims spawn the native binary." + }, + { + "name": "b", + "source": "cargo", + "alias_of": "build" + }, + { + "name": "bb", + "source": "cargo", + "alias_of": "build --bin run --bin runner" + }, + { + "name": "bbr", + "source": "cargo", + "alias_of": "build --bin run --bin runner --release" + }, + { + "name": "bin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run" + }, + { + "name": "bin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "c", + "source": "cargo", + "alias_of": "check" + }, + { + "name": "cl", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features" + }, + { + "name": "comp", + "source": "cargo", + "alias_of": "run --quiet --bin runner -- completions" + }, + { + "name": "d", + "source": "cargo", + "alias_of": "doc" + }, + { + "name": "f", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "format", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "i", + "source": "cargo", + "alias_of": "install --path ." + }, + { + "name": "l", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "lint", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "man", + "source": "cargo", + "alias_of": "run --quiet --features man -- man" + }, + { + "name": "meta", + "source": "cargo", + "alias_of": "metadata --format-version 1" + }, + { + "name": "r", + "source": "cargo", + "alias_of": "run" + }, + { + "name": "rbin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run --release" + }, + { + "name": "rbin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner --release" + }, + { + "name": "rm", + "source": "cargo", + "alias_of": "remove" + }, + { + "name": "rq", + "source": "cargo", + "alias_of": "run --quiet" + }, + { + "name": "rr", + "source": "cargo", + "alias_of": "run --release" + }, + { + "name": "runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "schema", + "source": "cargo", + "alias_of": "run --quiet --features schema -- schema" + }, + { + "name": "t", + "source": "cargo", + "alias_of": "test" + } + ] +} diff --git a/schemas/list.v1.schema.json b/schemas/list.v1.schema.json new file mode 100644 index 0000000..368c4bd --- /dev/null +++ b/schemas/list.v1.schema.json @@ -0,0 +1,92 @@ +{ + "$id": "https://kjanat.github.io/schemas/list.v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "TaskInfo": { + "description": "Task entry projected into the JSON shape.", + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "alias_of": { + "description": "When the task is an alias, the target it resolves to.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "Human-readable description, if any.", + "type": [ + "null", + "string" + ] + }, + "name": { + "description": "Task name as it appears in the config.", + "type": "string" + }, + "passthrough_to": { + "description": "When the task's body is a thin wrapper for another runner.", + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "Makefile", + "justfile", + "Taskfile", + "turbo.json", + "deno.json", + "cargo", + "go", + "bacon.toml", + "mise.toml", + "pyproject.toml" + ] + } + }, + "title": "runner list --json --schema-version 1", + "description": "JSON schema for `runner list --json --schema-version 1`.", + "type": "object", + "required": [ + "root", + "schema_version", + "tasks" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "root": { + "description": "Project root.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 1, + "minimum": 0, + "format": "uint32" + }, + "tasks": { + "description": "Tasks, optionally filtered by source.", + "type": "array", + "items": { + "$ref": "#/$defs/TaskInfo" + } + } + } +} diff --git a/schemas/list.v2.example.json b/schemas/list.v2.example.json new file mode 100644 index 0000000..139e3ea --- /dev/null +++ b/schemas/list.v2.example.json @@ -0,0 +1,173 @@ +{ + "schema_version": 2, + "root": "/home/kjanat/projects/runner", + "tasks": [ + { + "name": "fmt", + "source": "package.json" + }, + { + "name": "fmt:update", + "source": "package.json" + }, + { + "name": "typecheck", + "source": "package.json" + }, + { + "name": "build-packages", + "source": "just" + }, + { + "name": "default", + "source": "just" + }, + { + "name": "gen-schema", + "source": "just", + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" + }, + { + "name": "ls", + "source": "just" + }, + { + "name": "run", + "source": "just" + }, + { + "name": "runner", + "source": "just" + }, + { + "name": "test-release", + "source": "just", + "description": "Build release bin and verify the facade shims spawn the native binary." + }, + { + "name": "b", + "source": "cargo", + "alias_of": "build" + }, + { + "name": "bb", + "source": "cargo", + "alias_of": "build --bin run --bin runner" + }, + { + "name": "bbr", + "source": "cargo", + "alias_of": "build --bin run --bin runner --release" + }, + { + "name": "bin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run" + }, + { + "name": "bin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "c", + "source": "cargo", + "alias_of": "check" + }, + { + "name": "cl", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features" + }, + { + "name": "comp", + "source": "cargo", + "alias_of": "run --quiet --bin runner -- completions" + }, + { + "name": "d", + "source": "cargo", + "alias_of": "doc" + }, + { + "name": "f", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "format", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "i", + "source": "cargo", + "alias_of": "install --path ." + }, + { + "name": "l", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "lint", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "man", + "source": "cargo", + "alias_of": "run --quiet --features man -- man" + }, + { + "name": "meta", + "source": "cargo", + "alias_of": "metadata --format-version 1" + }, + { + "name": "r", + "source": "cargo", + "alias_of": "run" + }, + { + "name": "rbin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run --release" + }, + { + "name": "rbin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner --release" + }, + { + "name": "rm", + "source": "cargo", + "alias_of": "remove" + }, + { + "name": "rq", + "source": "cargo", + "alias_of": "run --quiet" + }, + { + "name": "rr", + "source": "cargo", + "alias_of": "run --release" + }, + { + "name": "runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "schema", + "source": "cargo", + "alias_of": "run --quiet --features schema -- schema" + }, + { + "name": "t", + "source": "cargo", + "alias_of": "test" + } + ] +} diff --git a/schemas/list.v2.schema.json b/schemas/list.v2.schema.json new file mode 100644 index 0000000..517e981 --- /dev/null +++ b/schemas/list.v2.schema.json @@ -0,0 +1,92 @@ +{ + "$id": "https://kjanat.github.io/schemas/list.v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "TaskInfo": { + "description": "Task entry projected into the JSON shape.", + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "alias_of": { + "description": "When the task is an alias, the target it resolves to.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "Human-readable description, if any.", + "type": [ + "null", + "string" + ] + }, + "name": { + "description": "Task name as it appears in the config.", + "type": "string" + }, + "passthrough_to": { + "description": "When the task's body is a thin wrapper for another runner.", + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + } + }, + "title": "runner list --json --schema-version 2", + "description": "JSON schema for `runner list --json --schema-version 2`.", + "type": "object", + "required": [ + "root", + "schema_version", + "tasks" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "root": { + "description": "Project root.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 2, + "minimum": 0, + "format": "uint32" + }, + "tasks": { + "description": "Tasks, optionally filtered by source.", + "type": "array", + "items": { + "$ref": "#/$defs/TaskInfo" + } + } + } +} diff --git a/schemas/runner.toml.schema.json b/schemas/runner.toml.schema.json index d928b06..4196f7c 100644 --- a/schemas/runner.toml.schema.json +++ b/schemas/runner.toml.schema.json @@ -1,35 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "RunnerConfig", - "description": "Top-level schema for `runner.toml`.", - "type": "object", - "properties": { - "chain": { - "description": "`[chain]` — failure policy for multi-task chains.", - "$ref": "#/$defs/ChainSection" - }, - "github": { - "description": "`[github]` — GitHub Actions integration (output grouping).", - "$ref": "#/$defs/GitHubSection" - }, - "parallel": { - "description": "`[parallel]` — presentation of parallel (`-p`) chain output.", - "$ref": "#/$defs/ParallelSection" - }, - "pm": { - "description": "`[pm]` — per-ecosystem package-manager overrides.", - "$ref": "#/$defs/PmSection" - }, - "resolution": { - "description": "`[resolution]` — resolver-policy knobs.", - "$ref": "#/$defs/ResolutionSection" - }, - "task_runner": { - "description": "`[task_runner]` — task-runner preferences.", - "$ref": "#/$defs/TaskRunnerSection" - } - }, - "additionalProperties": false, "$defs": { "ChainSection": { "description": "`[chain]` section — failure policy for `run -s/-p` chains and\n`runner install `.\n\n`Option` rather than `bool` so the resolver can distinguish\n\"user explicitly set false\" from \"user didn't say\": env-overrides-\nconfig layering means `[chain].keep_going = false` plus\n`RUNNER_KEEP_GOING=1` resolves to `true`.", @@ -37,23 +7,27 @@ "properties": { "keep_going": { "description": "Run every task in the chain to completion regardless of failures.\nMutually exclusive with `kill_on_fail`. Equivalent to `-k` /\n`RUNNER_KEEP_GOING`.", + "default": null, "type": [ "boolean", "null" - ], - "default": null + ] }, "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.", + "default": null, "type": [ "boolean", "null" - ], - "default": null + ] } }, "additionalProperties": false, "not": { + "required": [ + "keep_going", + "kill_on_fail" + ], "properties": { "keep_going": { "const": true @@ -61,11 +35,7 @@ "kill_on_fail": { "const": true } - }, - "required": [ - "keep_going", - "kill_on_fail" - ] + } } }, "GitHubSection": { @@ -74,13 +44,13 @@ "properties": { "group_output": { "description": "Wrap task output in `runner: ` groups under GitHub Actions.\nDefaults to `true`; set `false` to restore the old ungrouped output,\nincluding the live `[task]`-prefixed muxer for parallel runs.", - "type": "boolean", - "default": true + "default": true, + "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.", - "type": "boolean", - "default": true + "default": true, + "type": "boolean" } }, "additionalProperties": false @@ -91,8 +61,8 @@ "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.", - "type": "boolean", - "default": false + "default": false, + "type": "boolean" } }, "additionalProperties": false @@ -104,8 +74,8 @@ "node": { "description": "Package manager used to dispatch Node `package.json` scripts.\nValid values: `npm`, `pnpm`, `yarn`, `bun`, `deno`.", "type": [ - "string", - "null" + "null", + "string" ], "enum": [ "npm", @@ -119,8 +89,8 @@ "python": { "description": "Package manager used for Python ecosystems.\nValid values: `uv`, `poetry`, `pipenv`.", "type": [ - "string", - "null" + "null", + "string" ], "enum": [ "uv", @@ -139,8 +109,8 @@ "fallback": { "description": "`probe` (default) — PATH probe in canonical order when no signals\nmatch; `npm` — legacy silent fallback; `error` — refuse to proceed.", "type": [ - "string", - "null" + "null", + "string" ], "enum": [ "probe", @@ -152,8 +122,8 @@ "on_mismatch": { "description": "`warn` (default), `error`, `ignore` — how to react when declaration\n(manifest field) disagrees with detection (lockfile).", "type": [ - "string", - "null" + "null", + "string" ], "enum": [ "warn", @@ -171,8 +141,8 @@ "properties": { "prefer": { "description": "Ranked preference list. Restricts candidates to runners in the\nlist (in listed order); a same-named task under a runner not in\nthe list is hard-rejected. Parsed into [`crate::types::TaskRunner`]\nat resolver-init time so unknown labels fail fast.\n\nValid values: `turbo`, `nx`, `make`, `just`, `task`, `mise`,\n`bacon`. (Not constrained in the JSON Schema — the runtime\nparser emits a more helpful error than a schema-validation\nfailure would.)", - "type": "array", "default": [], + "type": "array", "items": { "type": "string" } @@ -180,5 +150,35 @@ }, "additionalProperties": false } - } + }, + "title": "RunnerConfig", + "description": "Top-level schema for `runner.toml`.", + "type": "object", + "properties": { + "chain": { + "$ref": "#/$defs/ChainSection", + "description": "`[chain]` — failure policy for multi-task chains." + }, + "github": { + "$ref": "#/$defs/GitHubSection", + "description": "`[github]` — GitHub Actions integration (output grouping)." + }, + "parallel": { + "$ref": "#/$defs/ParallelSection", + "description": "`[parallel]` — presentation of parallel (`-p`) chain output." + }, + "pm": { + "$ref": "#/$defs/PmSection", + "description": "`[pm]` — per-ecosystem package-manager overrides." + }, + "resolution": { + "$ref": "#/$defs/ResolutionSection", + "description": "`[resolution]` — resolver-policy knobs." + }, + "task_runner": { + "$ref": "#/$defs/TaskRunnerSection", + "description": "`[task_runner]` — task-runner preferences." + } + }, + "additionalProperties": false } diff --git a/schemas/why.v1.example.json b/schemas/why.v1.example.json new file mode 100644 index 0000000..f791bf5 --- /dev/null +++ b/schemas/why.v1.example.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "task": "t", + "candidates": [ + { + "source": "cargo", + "source_priority": 2, + "depth": 0, + "display_order": 6, + "is_alias": true, + "alias_of": "test", + "description": null, + "passthrough_to": null, + "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + } + ], + "selected": { + "source": "cargo", + "source_priority": 2, + "depth": 0, + "display_order": 6, + "is_alias": true, + "alias_of": "test", + "description": null, + "passthrough_to": null, + "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + }, + "pm_resolution": null +} diff --git a/schemas/why.v1.schema.json b/schemas/why.v1.schema.json new file mode 100644 index 0000000..a8a51d7 --- /dev/null +++ b/schemas/why.v1.schema.json @@ -0,0 +1,192 @@ +{ + "$id": "https://kjanat.github.io/schemas/why.v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "PmResolution": { + "anyOf": [ + { + "type": "object", + "required": [ + "pm", + "via", + "warnings" + ], + "properties": { + "pm": { + "type": "string" + }, + "via": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyWarning" + } + } + } + }, + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + ] + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "Makefile", + "justfile", + "Taskfile", + "turbo.json", + "deno.json", + "cargo", + "go", + "bacon.toml", + "mise.toml", + "pyproject.toml" + ] + }, + "WhyCandidate": { + "type": "object", + "required": [ + "alias_of", + "depth", + "description", + "display_order", + "is_alias", + "passthrough_to", + "source", + "source_dir", + "source_priority" + ], + "properties": { + "alias_of": { + "type": [ + "null", + "string" + ] + }, + "depth": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "format": "uint" + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "display_order": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "format": "uint8" + }, + "is_alias": { + "type": "boolean" + }, + "passthrough_to": { + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + }, + "source_dir": { + "type": [ + "null", + "string" + ] + }, + "source_priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "format": "uint16" + } + } + }, + "WhyWarning": { + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + }, + "title": "runner why --json --schema-version 1", + "description": "JSON schema for `runner why --json --schema-version 1`.", + "type": "object", + "required": [ + "candidates", + "pm_resolution", + "schema_version", + "selected", + "task" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyCandidate" + } + }, + "pm_resolution": { + "anyOf": [ + { + "$ref": "#/$defs/PmResolution" + }, + { + "type": "null" + } + ] + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 1, + "minimum": 0, + "format": "uint32" + }, + "selected": { + "anyOf": [ + { + "$ref": "#/$defs/WhyCandidate" + }, + { + "type": "null" + } + ] + }, + "task": { + "type": "string" + } + } +} diff --git a/schemas/why.v2.example.json b/schemas/why.v2.example.json new file mode 100644 index 0000000..363b1d0 --- /dev/null +++ b/schemas/why.v2.example.json @@ -0,0 +1,29 @@ +{ + "schema_version": 2, + "task": "t", + "candidates": [ + { + "source": "cargo", + "source_priority": 2, + "depth": 0, + "display_order": 6, + "is_alias": true, + "alias_of": "test", + "description": null, + "passthrough_to": null, + "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + } + ], + "selected": { + "source": "cargo", + "source_priority": 2, + "depth": 0, + "display_order": 6, + "is_alias": true, + "alias_of": "test", + "description": null, + "passthrough_to": null, + "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + }, + "pm_resolution": null +} diff --git a/schemas/why.v2.schema.json b/schemas/why.v2.schema.json new file mode 100644 index 0000000..12819ca --- /dev/null +++ b/schemas/why.v2.schema.json @@ -0,0 +1,192 @@ +{ + "$id": "https://kjanat.github.io/schemas/why.v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "PmResolution": { + "anyOf": [ + { + "type": "object", + "required": [ + "pm", + "via", + "warnings" + ], + "properties": { + "pm": { + "type": "string" + }, + "via": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyWarning" + } + } + } + }, + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + ] + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + }, + "WhyCandidate": { + "type": "object", + "required": [ + "alias_of", + "depth", + "description", + "display_order", + "is_alias", + "passthrough_to", + "source", + "source_dir", + "source_priority" + ], + "properties": { + "alias_of": { + "type": [ + "null", + "string" + ] + }, + "depth": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "format": "uint" + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "display_order": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "format": "uint8" + }, + "is_alias": { + "type": "boolean" + }, + "passthrough_to": { + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + }, + "source_dir": { + "type": [ + "null", + "string" + ] + }, + "source_priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "format": "uint16" + } + } + }, + "WhyWarning": { + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + }, + "title": "runner why --json --schema-version 2", + "description": "JSON schema for `runner why --json --schema-version 2`.", + "type": "object", + "required": [ + "candidates", + "pm_resolution", + "schema_version", + "selected", + "task" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyCandidate" + } + }, + "pm_resolution": { + "anyOf": [ + { + "$ref": "#/$defs/PmResolution" + }, + { + "type": "null" + } + ] + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 2, + "minimum": 0, + "format": "uint32" + }, + "selected": { + "anyOf": [ + { + "$ref": "#/$defs/WhyCandidate" + }, + { + "type": "null" + } + ] + }, + "task": { + "type": "string" + } + } +} diff --git a/schemas/why.v3-draft.example.json b/schemas/why.v3-draft.example.json new file mode 100644 index 0000000..bc028e0 --- /dev/null +++ b/schemas/why.v3-draft.example.json @@ -0,0 +1,67 @@ +{ + "schema_version": 3, + "kind": "runner.why", + "root": "/home/kjanat/projects/runner", + "query": "t", + + "pm_resolution": null, + + "selected": { + "task": { + "name": "t", + "fqn": "root:cargo-alias:t", + "provider": "cargo", + "kind": "cargo-alias", + "source": "/home/kjanat/projects/runner/.cargo/config.toml", + "source_pointer": "alias.t", + "description": null, + "aliases": [], + "definition": "test", + "resolved": "cargo test", + "cwd": "/home/kjanat/projects/runner", + "dependencies": [] + }, + "match": { + "selector": "t", + "matched_by": "name", + "depth": 0, + "display_order": 6, + "source_priority": 2, + "is_alias": true, + "passthrough_to": null + } + }, + + "candidates": [ + { + "task": { + "name": "t", + "fqn": "root:cargo-alias:t", + "provider": "cargo", + "kind": "cargo-alias", + "source": "/home/kjanat/projects/runner/.cargo/config.toml", + "source_pointer": "alias.t", + "description": null, + "aliases": [], + "definition": "test", + "resolved": "cargo test", + "cwd": "/home/kjanat/projects/runner", + "dependencies": [] + }, + "match": { + "selector": "t", + "matched_by": "name", + "depth": 0, + "display_order": 6, + "source_priority": 2, + "is_alias": true, + "passthrough_to": null + } + } + ], + + "decision": { + "strategy": "single-candidate", + "reason": "exact task name matched one candidate" + } +} diff --git a/src/cli.rs b/src/cli.rs index 24c4366..394d0fa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -991,14 +991,17 @@ pub(crate) enum Command { output: Option, }, - /// Emit the runner.toml JSON Schema (build: --features schema) + /// Emit JSON Schemas (build: --features schema) #[cfg(feature = "schema")] Schema { - /// Write the schema to this file instead of stdout. + /// Emit every committed schema into the output directory. + #[arg(long)] + all: bool, + /// Write the schema to this file, or all schemas to this directory with --all. #[arg( short = 'o', long = "output", - value_name = "FILE", + value_name = "PATH", value_hint = clap::ValueHint::FilePath, value_parser = clap::value_parser!(PathBuf), )] diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index c4bfad9..c960e0e 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -1,15 +1,110 @@ -//! `runner schema` — emit the `runner.toml` JSON Schema (feature `schema`). +//! `runner schema` — emit committed JSON Schemas (feature `schema`). use std::io::Write as _; use std::path::Path; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, bail}; +use schemars::{JsonSchema, Schema}; +use serde_json::{Map, Value, json}; -/// Write the schema to `output`, or to stdout when `None`. A trailing newline -/// is appended so the committed `schemas/*.json` ends cleanly. -pub(crate) fn write_schema(output: Option<&Path>) -> Result<()> { - let schema = crate::config_schema(); - let json = serde_json::to_string_pretty(&schema).context("failed to serialize schema")?; +use crate::schema::{Project, project::TaskListView}; + +const SCHEMA_DIR: &str = "schemas"; + +struct SchemaDocument { + filename: &'static str, + value: Value, +} + +/// Write the config schema to stdout/a file, or every committed schema to a directory. +/// A trailing newline is appended so committed `schemas/*.json` ends cleanly. +pub(crate) fn write_schema(all: bool, output: Option<&Path>) -> Result<()> { + if all { + let dir = output.unwrap_or_else(|| Path::new(SCHEMA_DIR)); + write_all_schemas(dir) + } else { + write_json( + output, + &schema_value(schemars::schema_for!(crate::config::RunnerConfig))?, + ) + } +} + +fn write_all_schemas(dir: &Path) -> Result<()> { + if dir.exists() && !dir.is_dir() { + bail!("--all output must be a directory: {}", dir.display()); + } + std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?; + + for document in schema_documents()? { + write_json(Some(&dir.join(document.filename)), &document.value)?; + } + + Ok(()) +} + +fn schema_documents() -> Result> { + Ok(vec![ + SchemaDocument { + filename: "runner.toml.schema.json", + value: schema_value(schemars::schema_for!(crate::config::RunnerConfig))?, + }, + SchemaDocument { + filename: "doctor.v1.schema.json", + value: output_schema::>("doctor", 1)?, + }, + SchemaDocument { + filename: "doctor.v2.schema.json", + value: output_schema::>("doctor", 2)?, + }, + SchemaDocument { + filename: "list.v1.schema.json", + value: output_schema::>("list", 1)?, + }, + SchemaDocument { + filename: "list.v2.schema.json", + value: output_schema::>("list", 2)?, + }, + SchemaDocument { + filename: "why.v1.schema.json", + value: output_schema::>("why", 1)?, + }, + SchemaDocument { + filename: "why.v2.schema.json", + value: output_schema::>("why", 2)?, + }, + ]) +} + +fn output_schema(command: &'static str, version: u32) -> Result { + let mut schema = serialize_schema_value::()?; + set_object_field(&mut schema, "$id", json!(schema_id(command, version))); + set_object_field(&mut schema, "title", json!(title(command, version))); + set_object_field( + &mut schema, + "description", + json!(description(command, version)), + ); + patch_schema_version_const(&mut schema, version); + patch_source_schema(&mut schema, version); + Ok(schema) +} + +fn serialize_schema_value() -> Result { + let generator = schemars::generate::SchemaSettings::default() + .for_serialize() + .into_generator(); + schema_value(generator.into_root_schema_for::()) +} + +fn schema_value(schema: Schema) -> Result { + serde_json::to_value(schema).context("failed to serialize schema") +} + +fn write_json(output: Option<&Path>, value: &Value) -> Result<()> { + let mut sorted = value.clone(); + json_schema_sort::sort_schema(&mut sorted); + let json = serde_json::to_string_pretty(&sorted).context("failed to serialize schema")?; output.map_or_else( || writeln!(std::io::stdout(), "{json}").context("failed to write schema to stdout"), |path| { @@ -18,3 +113,109 @@ pub(crate) fn write_schema(output: Option<&Path>) -> Result<()> { }, ) } + +fn set_object_field(schema: &mut Value, key: &'static str, value: Value) { + if let Some(object) = schema.as_object_mut() { + object.insert(key.to_string(), value); + } +} + +fn patch_schema_version_const(schema: &mut Value, version: u32) { + let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else { + return; + }; + let Some(version_schema) = properties + .get_mut("schema_version") + .and_then(Value::as_object_mut) + else { + return; + }; + version_schema.insert("const".to_string(), json!(version)); +} + +fn patch_source_schema(schema: &mut Value, version: u32) { + let Some(defs) = schema.get_mut("$defs").and_then(Value::as_object_mut) else { + return; + }; + + defs.insert( + "TaskSourceLabel".to_string(), + task_source_label_schema(version), + ); + patch_task_info_source(defs); + patch_why_candidate_source(defs); +} + +fn patch_task_info_source(defs: &mut Map) { + patch_def_source(defs, "TaskInfo"); +} + +fn patch_why_candidate_source(defs: &mut Map) { + patch_def_source(defs, "WhyCandidate"); +} + +fn patch_def_source(defs: &mut Map, def_name: &'static str) { + let Some(source_schema) = defs + .get_mut(def_name) + .and_then(|definition| definition.get_mut("properties")) + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut("source")) + else { + return; + }; + *source_schema = json!({ "$ref": "#/$defs/TaskSourceLabel" }); +} + +fn task_source_label_schema(version: u32) -> Value { + json!({ "type": "string", "enum": source_labels(version) }) +} + +fn source_labels(version: u32) -> &'static [&'static str] { + match version { + 1 => &[ + "package.json", + "Makefile", + "justfile", + "Taskfile", + "turbo.json", + "deno.json", + "cargo", + "go", + "bacon.toml", + "mise.toml", + "pyproject.toml", + ], + _ => &[ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "pyproject.toml", + ], + } +} + +fn schema_id(command: &str, version: u32) -> String { + format!("https://kjanat.github.io/schemas/{command}.v{version}.schema.json") +} + +fn title(command: &str, version: u32) -> String { + match command { + "why" => format!("runner why --json --schema-version {version}"), + _ => format!("runner {command} --json --schema-version {version}"), + } +} + +fn description(command: &str, version: u32) -> String { + match (command, version) { + ("doctor", 1) => "JSON schema for the legacy v1 `runner doctor --json` document. v1 uses filename-style task source labels.".to_string(), + ("doctor", _) => "JSON schema for the current v2 `runner doctor --json` document. v2 uses tool-name task source labels.".to_string(), + _ => format!("JSON schema for `{}`.", title(command, version)), + } +} diff --git a/src/cmd/why.rs b/src/cmd/why.rs index ac2d3bf..d97e154 100644 --- a/src/cmd/why.rs +++ b/src/cmd/why.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use anyhow::Result; use colored::Colorize; -use serde_json::{Value, json}; +use serde::Serialize; use crate::cmd::run::{ ResolvedPythonPm, allowed_runner_sources, resolve_python_pm, runner_constraint_error, @@ -86,6 +86,61 @@ enum PmDecision { Python(Result), } +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +pub(super) struct WhyReport<'a> { + #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] + #[cfg_attr( + feature = "schema", + schemars(description = "URI of the JSON Schema that describes this payload.") + )] + schema: String, + #[cfg_attr( + feature = "schema", + schemars(description = "Schema contract version for this JSON payload.") + )] + schema_version: u32, + task: &'a str, + candidates: Vec>, + selected: Option>, + pm_resolution: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct WhyCandidate<'a> { + source: &'static str, + source_priority: u16, + depth: Option, + display_order: u8, + is_alias: bool, + alias_of: Option<&'a str>, + description: Option<&'a str>, + passthrough_to: Option<&'static str>, + source_dir: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum PmResolution { + Resolved { + pm: &'static str, + via: String, + warnings: Vec, + }, + Error { + error: String, + }, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct WhyWarning { + source: &'static str, + detail: String, +} + fn pm_decision_for_selected( ctx: &ProjectContext, overrides: &ResolutionOverrides, @@ -105,69 +160,77 @@ fn pm_decision_for_selected( } } -fn build_report( - task: &str, - candidates: &[&Task], - selected: Option<&Task>, +fn build_report<'a>( + task: &'a str, + candidates: &[&'a Task], + selected: Option<&'a Task>, pm_decision: Option<&PmDecision>, overrides: &ResolutionOverrides, ctx: &ProjectContext, schema_version: u32, -) -> Value { - json!({ - "schema_version": schema_version, - "task": task, - "candidates": candidates.iter() - .map(|c| candidate_json(c, overrides, ctx, schema_version)) +) -> WhyReport<'a> { + WhyReport { + schema: String::new(), + schema_version, + task, + candidates: candidates + .iter() + .map(|candidate| candidate_json(candidate, overrides, ctx, schema_version)) .collect::>(), - "selected": selected.map(|s| candidate_json(s, overrides, ctx, schema_version)), - "pm_resolution": pm_decision.map(pm_decision_json), - }) + selected: selected.map(|task| candidate_json(task, overrides, ctx, schema_version)), + pm_resolution: pm_decision.map(pm_resolution), + } } -fn pm_decision_json(decision: &PmDecision) -> Value { +fn pm_resolution(decision: &PmDecision) -> PmResolution { match decision { - PmDecision::Node(Ok(decision)) => json!({ - "pm": decision.pm.label(), - "via": decision.describe(), - "warnings": decision.warnings.iter().map(|w| json!({ - "source": w.source(), - "detail": w.detail(), - })).collect::>(), - }), - PmDecision::Node(Err(err)) => json!({ "error": format!("{err}") }), - PmDecision::Python(Ok(decision)) => json!({ - "pm": decision.pm.label(), - "via": decision.describe(), - "warnings": [], - }), - PmDecision::Python(Err(err)) => json!({ "error": err }), + PmDecision::Node(Ok(decision)) => PmResolution::Resolved { + pm: decision.pm.label(), + via: decision.describe(), + warnings: decision + .warnings + .iter() + .map(|warning| WhyWarning { + source: warning.source(), + detail: warning.detail(), + }) + .collect(), + }, + PmDecision::Node(Err(err)) => PmResolution::Error { + error: format!("{err}"), + }, + PmDecision::Python(Ok(decision)) => PmResolution::Resolved { + pm: decision.pm.label(), + via: decision.describe(), + warnings: Vec::new(), + }, + PmDecision::Python(Err(err)) => PmResolution::Error { error: err.clone() }, } } -fn candidate_json( - task: &Task, +fn candidate_json<'a>( + task: &'a Task, overrides: &ResolutionOverrides, ctx: &ProjectContext, schema_version: u32, -) -> Value { +) -> WhyCandidate<'a> { let depth = source_depth(ctx, task.source); - let depth_value = if depth == usize::MAX { - Value::Null + let depth = if depth == usize::MAX { + None } else { - json!(depth) + Some(depth) }; - json!({ - "source": crate::schema::labels::source_label_for(task.source, schema_version), - "source_priority": source_priority(overrides, task.source), - "depth": depth_value, - "display_order": task.source.display_order(), - "is_alias": task.alias_of.is_some(), - "alias_of": task.alias_of, - "description": task.description, - "passthrough_to": task.passthrough_to.map(crate::types::TaskRunner::label), - "source_dir": source_dir_for_task(task, ctx).map(|p| p.display().to_string()), - }) + WhyCandidate { + source: crate::schema::labels::source_label_for(task.source, schema_version), + source_priority: source_priority(overrides, task.source), + depth, + display_order: task.source.display_order(), + is_alias: task.alias_of.is_some(), + alias_of: task.alias_of.as_deref(), + description: task.description.as_deref(), + passthrough_to: task.passthrough_to.map(crate::types::TaskRunner::label), + source_dir: source_dir_for_task(task, ctx).map(|path| path.display().to_string()), + } } fn source_dir_for_task(task: &Task, ctx: &ProjectContext) -> Option { @@ -386,6 +449,7 @@ mod tests { &ctx, crate::schema::CURRENT_VERSION, ); + let report = serde_json::to_value(report).expect("why report should serialize"); assert_eq!(report["pm_resolution"]["pm"], serde_json::json!("uv")); assert!( diff --git a/src/lib.rs b/src/lib.rs index c8ae401..729e45f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -765,7 +765,7 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { #[cfg(feature = "man")] Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()), #[cfg(feature = "schema")] - Some(cli::Command::Schema { output }) => dispatch_schema(output.as_deref()), + Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()), Some(cli::Command::Doctor { json }) => { let schema_version = schema_version_for_json(json, cli.global.schema_version)?; cmd::doctor(&ctx, &overrides, json, schema_version)?; @@ -789,8 +789,8 @@ fn dispatch_man(output: Option<&Path>) -> Result { } #[cfg(feature = "schema")] -fn dispatch_schema(output: Option<&Path>) -> Result { - cmd::write_schema(output)?; +fn dispatch_schema(all: bool, output: Option<&Path>) -> Result { + cmd::write_schema(all, output)?; Ok(0) } diff --git a/src/resolver/overrides.rs b/src/resolver/overrides.rs index d5b137d..82dbcb5 100644 --- a/src/resolver/overrides.rs +++ b/src/resolver/overrides.rs @@ -429,15 +429,21 @@ fn lenient_env_field( return; }; if let Err(err) = validate(raw) { + let sanitized = sanitize_raw_label(raw); warnings.push(DetectionWarning::InvalidEnvOverride { var, - raw: sanitize_raw_label(raw), - message: format!("{err}"), + raw: sanitized.clone(), + message: sanitize_error_message(raw, &sanitized, &format!("{err}")), }); field.env = None; } } +fn sanitize_error_message(raw: &str, sanitized: &str, message: &str) -> String { + let escaped: String = raw.chars().flat_map(char::escape_debug).collect(); + message.replace(raw, sanitized).replace(&escaped, sanitized) +} + /// Source names for the cross-ecosystem PM override. const PM_SOURCE_NAMES: SourceNames = SourceNames { cli: "--pm", diff --git a/src/schema/project.rs b/src/schema/project.rs index 61956d8..bcf6d4f 100644 --- a/src/schema/project.rs +++ b/src/schema/project.rs @@ -26,10 +26,22 @@ use crate::types::{DetectionWarning, PackageManager, ProjectContext, TaskSource} /// The canonical machine-readable view of a project, used by every /// `--json` surface. Field order is preserved by `serde_json` so /// consumers can hand-write `jq` queries without sort surprises. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Project<'a> { + /// URI of the JSON Schema that describes this payload. + #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] + #[cfg_attr( + feature = "schema", + schemars(description = "URI of the JSON Schema that describes this payload.") + )] + pub schema: String, /// Increments on any breaking change to this schema. Consumers /// should reject anything they weren't built for. + #[cfg_attr( + feature = "schema", + schemars(description = "Schema contract version for this JSON payload.") + )] pub schema_version: u32, /// Absolute path of the project root the report describes. pub root: String, @@ -120,6 +132,7 @@ impl<'a> Project<'a> { let probes = probe_signals(&ctx.root, resolve_shims); Self { + schema: String::new(), schema_version, root: ctx.root.display().to_string(), ecosystems: ctx @@ -168,6 +181,7 @@ impl<'a> Project<'a> { .filter(|t| target.is_none_or(|expected| expected == t.source)) .collect(); TaskListView { + schema: String::new(), schema_version: self.schema_version, root: self.root, tasks, @@ -177,10 +191,22 @@ impl<'a> Project<'a> { /// `list --json` projection. Same `schema_version` as [`Project`] so /// consumers can branch on it. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct TaskListView<'a> { + /// URI of the JSON Schema that describes this payload. + #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] + #[cfg_attr( + feature = "schema", + schemars(description = "URI of the JSON Schema that describes this payload.") + )] + pub schema: String, /// Identical to [`Project::schema_version`]; consumers can assume /// `1` here means a v1-shaped `tasks` array. + #[cfg_attr( + feature = "schema", + schemars(description = "Schema contract version for this JSON payload.") + )] pub schema_version: u32, /// Project root. pub root: String, @@ -190,6 +216,7 @@ pub(crate) struct TaskListView<'a> { /// Detection results — what the file scan found, before any resolver /// policy was applied. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Detected<'a> { /// Detected package managers, in detection-priority order. @@ -220,6 +247,7 @@ impl<'a> Detected<'a> { } /// Node version declaration plus the file it came from. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct NodeVersionInfo<'a> { /// Version string as written (e.g. `"20.11.0"`, `">=18"`). @@ -230,6 +258,7 @@ pub(crate) struct NodeVersionInfo<'a> { /// Materialised override stack — the inputs that fed into resolver /// decisions. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct OverridesView { /// Cross-ecosystem PM override from `--pm` / `RUNNER_PM`. @@ -282,6 +311,7 @@ impl OverridesView { } /// PM override + provenance. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct PmOverrideInfo { /// The chosen PM label. @@ -291,6 +321,7 @@ pub(crate) struct PmOverrideInfo { } /// Task-runner override + provenance. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct RunnerOverrideInfo { /// The chosen runner label. @@ -300,6 +331,7 @@ pub(crate) struct RunnerOverrideInfo { } /// Per-ecosystem signals — what the resolver had to work with. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Signals { /// Node-ecosystem signals. The schema is intentionally @@ -309,6 +341,7 @@ pub(crate) struct Signals { } /// Node-ecosystem detection signals: lockfile, manifest, PATH probe. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct NodeSignals { /// PM inferred from the highest-priority lockfile, if any. @@ -325,6 +358,7 @@ pub(crate) struct NodeSignals { } /// What `volta which` said about one shimmed tool. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct VoltaShimInfo { /// Real provisioned binary behind the shim; `null` when Volta has @@ -334,6 +368,7 @@ pub(crate) struct VoltaShimInfo { } /// Manifest-level PM declaration plus the field it came from. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct ManifestPm { /// Declared PM label. @@ -348,6 +383,7 @@ pub(crate) struct ManifestPm { /// Resolver verdict surface. Mirrors the resolver's `Result` so /// consumers can branch on the variant before reading the inner shape. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Decisions { /// Node script-dispatch PM decision, or an error message when the @@ -358,6 +394,7 @@ pub(crate) struct Decisions { /// Either a resolved Node PM or the diagnostic string for the failure /// that prevented one. Untagged so consumers can probe via "is the /// `pm` field present?". +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[serde(untagged)] pub(crate) enum NodePmDecision { @@ -376,6 +413,7 @@ pub(crate) enum NodePmDecision { } /// Task entry projected into the JSON shape. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct TaskInfo<'a> { /// Task name as it appears in the config. @@ -398,6 +436,7 @@ pub(crate) struct TaskInfo<'a> { /// is kept stable from the pre-A4 flat-struct days so existing /// consumers (the `doctor` test suite, ad-hoc `jq` queries) keep /// working. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct WarningInfo { /// Subsystem the warning came from (e.g. `"package.json"`).