runner-run 0.19.1

Universal project task runner
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
//! Resolver data types — the public structs/enums + their trivial impls.
//!
//! No resolution logic, no parsing — just the shapes the rest of the
//! resolver passes around. `impl Resolver` lives in [`super::resolve`];
//! `impl ResolutionOverrides` lives in [`super::overrides`].

use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;

use crate::chain::FailurePolicy;
use crate::config::LoadedConfig;
use crate::tool::node::OnFail;
use crate::types::{
    DetectionWarning, Ecosystem, PackageManager, ProjectContext, TaskRunner, TaskSource,
};

/// Resolves package managers and task sources from a [`ProjectContext`]
/// plus a bundle of [`ResolutionOverrides`].
pub(crate) struct Resolver<'ctx> {
    pub(super) ctx: &'ctx ProjectContext,
    pub(super) overrides: &'ctx ResolutionOverrides,
}

/// User-supplied overrides assembled from CLI flags, environment variables,
/// and (Phase 3+) a `runner.toml` file.
///
/// Each field carries an [`OverrideOrigin`] so diagnostic output (Phase 6)
/// can attribute a decision to the exact source the user set it from.
#[derive(Debug, Clone, Default)]
#[allow(
    clippy::struct_excessive_bools,
    reason = "settings bag of independent CLI/env/config toggles; an enum state machine would \
              obscure them, not clarify"
)]
pub(crate) struct ResolutionOverrides {
    /// Cross-ecosystem PM override from CLI/env. `--pm`/`RUNNER_PM` are not
    /// ecosystem-qualified; the resolver applies this value only when the
    /// named PM is compatible with the requested ecosystem.
    pub pm: Option<PmOverride>,
    /// Per-ecosystem PM overrides from `runner.toml`. Consulted after the
    /// cross-ecosystem CLI/env override falls through (e.g. `--pm cargo`
    /// against a Node resolution).
    pub pm_by_ecosystem: HashMap<Ecosystem, PmOverride>,
    /// Task-runner override from `--runner` / `RUNNER_RUNNER`. When set,
    /// the source selector restricts candidates to that runner's
    /// [`TaskRunner::task_source`]; an empty restriction list bails with
    /// [`super::ResolveError::InvalidOverride`].
    pub runner: Option<RunnerOverride>,
    /// Ranked preference list from the **deprecated** `[task_runner].prefer`.
    /// Empty when no config is loaded, the section is empty, or `[tasks]`
    /// supersedes it. When non-empty, the source selector restricts candidates
    /// to runners in the list (in listed order); a miss bails with
    /// [`super::ResolveError::InvalidOverride`].
    pub prefer_runners: Vec<TaskRunner>,
    /// Global rank-only task-source order from `[tasks].prefer`. Empty when
    /// unset. Each entry is a [`TaskSource`] (resolved from a runner, package
    /// manager, or source label); a same-name conflict prefers earlier
    /// entries, and any source not listed still resolves (it just ranks
    /// below listed ones). Never restricts.
    pub prefer_sources: Vec<TaskSource>,
    /// Per-task source pins from `[tasks].overrides`: task name → preferred
    /// [`TaskSource`]s, most-native first. When a pinned task has a candidate
    /// under one of these sources, that candidate wins; otherwise the normal
    /// ranking applies (no hard error).
    pub task_source_overrides: BTreeMap<String, Vec<TaskSource>>,
    /// What to do when no signal in steps 2–6 matches.
    pub fallback: FallbackPolicy,
    /// What to do when the manifest declaration (step 5) disagrees with
    /// the detected lockfile (step 6).
    pub on_mismatch: MismatchPolicy,
    /// When `true`, suppress all `DetectionWarning` output. Set via
    /// `--no-warnings` / `RUNNER_NO_WARNINGS`. Errors still surface;
    /// only non-fatal warnings are silenced.
    pub no_warnings: bool,
    /// When `true`, suppress the dispatch arrow (`→ <source> <task>`) and
    /// the dispatch-time `--explain` trace on stderr. Set via `--quiet` /
    /// `RUNNER_QUIET`.
    pub quiet: bool,
    /// When `true`, emit a one-line trace describing which chain step
    /// produced the PM decision. Set via `--explain` / `RUNNER_EXPLAIN`.
    pub explain: bool,
    /// Failure policy for `run -s/-p` chains and `runner install <tasks>`.
    /// Resolved from `-k`/`-K` (CLI) → `RUNNER_KEEP_GOING`/`RUNNER_KILL_ON_FAIL`
    /// (env) → `[chain]` (config) → `FailFast`.
    pub failure_policy: FailurePolicy,
    /// Broad GitHub Actions grouping switch. Sourced from
    /// `[github].group_output` (default `true`); when false, GitHub Actions
    /// runs use the same ungrouped output shape as before this feature.
    pub group_output: bool,
    /// Whether to group parallel (`-p`) output **under GitHub Actions**:
    /// buffer each task and print it as one block on completion rather than
    /// interleaving lines live. Sourced from `[github].group_parallel`
    /// (default `true`) and only active when [`Self::group_output`] is true.
    /// The emit site picks this under GitHub Actions and
    /// [`Self::parallel_grouped`] otherwise.
    pub github_group_parallel: bool,
    /// Whether to group parallel (`-p`) output **outside GitHub Actions**.
    /// Sourced from `[parallel].grouped` (default `false`), so by default
    /// local parallel runs stay live-prefixed while CI groups; set them to
    /// match if desired. Only the delimiter style (`::group::` vs a plain
    /// header) further depends on the environment.
    pub parallel_grouped: bool,
    /// Allowlist of package managers `runner install` may run, resolved
    /// from `RUNNER_INSTALL_PMS` (env) → `[install].pms` (config). Empty
    /// means "no install filter" — install fans out to every detected PM.
    /// Unlike [`Self::pm`], this never affects script dispatch.
    pub install_pms: Vec<PackageManager>,
    /// Install-time lifecycle-script policy, resolved from
    /// `RUNNER_INSTALL_SCRIPTS` (env) → `[install].scripts` (config). The CLI
    /// `--no-scripts` ([`ScriptPolicy::Deny`]) / `--scripts`
    /// ([`ScriptPolicy::Allow`]) flags are layered on top at the dispatch
    /// boundary; [`ScriptPolicy::Default`] leaves each package manager at its
    /// own built-in default.
    pub script_policy: ScriptPolicy,
    /// `true` when a parent `runner`/`run` already opened a GitHub Actions
    /// log group above this process (signalled via the inherited
    /// `RUNNER_GROUP_ACTIVE` env marker). GitHub Actions groups don't nest —
    /// a nested `::endgroup::` closes the parent's group early — so when this
    /// is set, this runner's own group-opening sites stay silent and output
    /// flows into the parent's group. Internal/runner-set, never a user
    /// override.
    pub parent_group_open: bool,
}

/// What to do when no signal in steps 2–6 matches.
///
/// Set via `--fallback` / `RUNNER_FALLBACK` / `[resolution].fallback`.
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum FallbackPolicy {
    /// Walk `$PATH` in canonical order and pick the first installed PM.
    /// Errors if nothing matches.
    #[default]
    Probe,
    /// Legacy: silently default to `npm` so dispatch is attempted even
    /// when nothing is detected. Useful for backwards compatibility.
    Npm,
    /// Refuse to proceed when no signal matches; error out with a list of
    /// sources that were checked.
    Error,
}

impl FallbackPolicy {
    /// Every variant, in the order [`Self::label`]'s callers should list
    /// them. Single source of truth for
    /// [`super::policies::parse_fallback_label`] and any surface that
    /// needs to advertise or validate against the same closed set.
    pub(crate) const ALL: [Self; 3] = [Self::Probe, Self::Npm, Self::Error];

    /// The `--fallback` / `RUNNER_FALLBACK` / `[resolution].fallback` label.
    pub(crate) const fn label(self) -> &'static str {
        match self {
            Self::Probe => "probe",
            Self::Npm => "npm",
            Self::Error => "error",
        }
    }
}

/// Install-time lifecycle-script execution policy for `runner install`.
///
/// Lifecycle/build scripts (`postinstall`, native-extension compilation,
/// …) are the primary supply-chain attack surface during dependency
/// installs. This knob lets a project deny them across the package managers
/// that expose a skip mechanism, or force them on across the managers that
/// can express it — the latter matters because several package managers
/// (npm, pnpm, …) are moving to scripts-off-by-default in upcoming majors.
///
/// Set via `--no-scripts` (deny) / `--scripts` (force on) on the CLI,
/// `RUNNER_INSTALL_SCRIPTS` (env), or `[install].scripts` (config), highest
/// first.
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ScriptPolicy {
    /// Leave each package manager at its own built-in default: npm,
    /// yarn-classic, pnpm (<10) and composer run dependency scripts, while
    /// bun, pnpm (>=10) and deno already deny them.
    #[default]
    Default,
    /// Skip lifecycle scripts wherever the package manager exposes a skip
    /// mechanism (npm/yarn/pnpm/bun `--ignore-scripts`, composer
    /// `--no-scripts`, yarn-berry `YARN_ENABLE_SCRIPTS=false`); deno already
    /// denies by default. Managers without one (cargo, go, bundler, and the
    /// Python backends uv/poetry/pipenv) warn and proceed unchanged.
    Deny,
    /// Force lifecycle scripts on wherever the package manager can express it:
    /// npm `--no-ignore-scripts`, yarn-berry `YARN_ENABLE_SCRIPTS=true`, deno
    /// `--allow-scripts` (allow all). Managers that already run scripts by
    /// default (composer, cargo, go, bundler, the Python backends, yarn-classic)
    /// are satisfied without a flag. bun and pnpm (>=10) deny dependency build
    /// scripts by default and re-enable them only through a manifest allowlist
    /// (`trustedDependencies` / `onlyBuiltDependencies`) that runner must not
    /// write, so they warn that force-on is not flag-expressible.
    Allow,
}

impl ScriptPolicy {
    /// The two labels a user can actually type — `Default` is the
    /// internal "unset" sentinel, never a valid `[install].scripts` /
    /// `RUNNER_INSTALL_SCRIPTS` value. Single source of truth for
    /// [`super::overrides::parse_script_policy_label`].
    pub(crate) const SETTABLE: [Self; 2] = [Self::Deny, Self::Allow];

    /// The user-facing label, or `None` for [`Self::Default`] (never
    /// user-settable — see [`Self::SETTABLE`]).
    pub(crate) const fn label(self) -> Option<&'static str> {
        match self {
            Self::Default => None,
            Self::Deny => Some("deny"),
            Self::Allow => Some("allow"),
        }
    }
}

/// How to react when manifest declaration (step 5) and lockfile (step 6)
/// disagree about which package manager the project uses.
///
/// Set via `--on-mismatch` / `RUNNER_ON_MISMATCH` /
/// `[resolution].on_mismatch`. Independent from
/// `devEngines.packageManager` `onFail` — that policy governs whether
/// the *declared* PM can actually run; this one governs whether the
/// resolver tolerates the declaration disagreeing with the install
/// state at all.
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum MismatchPolicy {
    /// Emit a `package.json` warning, prefer the declaration (Corepack
    /// semantics — the lockfile is most likely stale).
    #[default]
    Warn,
    /// Stay silent; prefer the declaration.
    Ignore,
    /// Bail with [`super::ResolveError::MismatchPolicyError`]. Intended for
    /// CI guardrails where a mismatch should block the run.
    Error,
}

impl MismatchPolicy {
    /// Every variant, in the order [`Self::label`]'s callers should list
    /// them. Single source of truth for
    /// [`super::policies::parse_mismatch_label`] and any surface that
    /// needs to advertise or validate against the same closed set.
    pub(crate) const ALL: [Self; 3] = [Self::Warn, Self::Ignore, Self::Error];

    /// The `--on-mismatch` / `RUNNER_ON_MISMATCH` / `[resolution].on_mismatch`
    /// label.
    pub(crate) const fn label(self) -> &'static str {
        match self {
            Self::Warn => "warn",
            Self::Ignore => "ignore",
            Self::Error => "error",
        }
    }
}

/// A package-manager override plus the source the user set it from.
#[derive(Debug, Clone)]
pub(crate) struct PmOverride {
    /// The chosen package manager.
    pub pm: PackageManager,
    /// Where the override came from.
    pub origin: OverrideOrigin,
}

/// A task-runner override plus the source the user set it from.
#[derive(Debug, Clone)]
pub(crate) struct RunnerOverride {
    /// The chosen task runner.
    pub runner: TaskRunner,
    /// Where the override came from. Surfaced by `--explain` and `doctor`
    /// so the user can attribute the constraint to its origin.
    #[allow(
        dead_code,
        reason = "consumed by --explain in Phase 6; kept on the type for future trace renderers"
    )]
    pub origin: OverrideOrigin,
}

/// Source the user set an override from.
///
/// Listed in precedence order, highest first.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum OverrideOrigin {
    /// Set via `--pm` / `--runner` on the command line.
    CliFlag,
    /// Set via `RUNNER_PM` / `RUNNER_RUNNER` in the environment.
    EnvVar,
    /// Set via a `runner.toml` at the project root.
    ConfigFile {
        /// Absolute path the override was loaded from. Surfaced by
        /// `ResolvedPm::describe` (which feeds `--explain` and the
        /// `doctor` trace) so the user can attribute a decision to the
        /// exact config file it came from.
        path: PathBuf,
    },
}

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 <path>`. 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 {
    /// The chosen package manager.
    pub pm: PackageManager,
    /// Which step of the resolution chain produced [`Self::pm`].
    /// Surfaced by [`Self::describe`] for `--explain` and the
    /// `doctor` / `why` traces.
    pub via: ResolutionStep,
    /// Non-fatal warnings emitted while resolving — e.g. a manifest
    /// declaration that disagrees with the detected lockfile.
    pub warnings: Vec<DetectionWarning>,
}

/// Which step of the resolution chain produced a decision.
///
/// Listed in precedence order. Downstream `match` sites stay exhaustive so
/// that adding a step is a compile error to handle.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ResolutionStep {
    /// Steps 2–4 — user-supplied override won.
    Override(OverrideOrigin),
    /// Step 5a — `package.json` legacy `packageManager` field.
    ManifestPackageManager,
    /// Step 5b — `package.json` `devEngines.packageManager` field.
    ManifestDevEngines {
        /// Effective `onFail` value for the chosen entry. Rendered into
        /// the `--explain` / `doctor` trace via [`ResolvedPm::describe`].
        on_fail: OnFail,
    },
    /// Step 6 — package manager inferred from a lockfile (or another
    /// detector recorded in [`ProjectContext::package_managers`]).
    Lockfile,
    /// Step 7 — discovered via `$PATH` probe in canonical order.
    PathProbe {
        /// Absolute path of the executable found on PATH. Rendered by
        /// [`ResolvedPm::describe`] so the user can spot which directory
        /// the resolver fell back to.
        binary: PathBuf,
    },
    /// Step 8 (legacy) — no signals matched, default to `npm` so that
    /// `runner run <script>` still has a chance to dispatch. The Phase 5
    /// default replaces this with a [`Self::PathProbe`]; this variant only
    /// fires with `--fallback npm`.
    LegacyNpmFallback,
}

/// Sources contributing to a [`ResolutionOverrides`].
///
/// Bundles every CLI/env input the resolver consumes so
/// `ResolutionOverrides::from_sources` stays extensible — adding a new
/// override (say `--on-mismatch` / `RUNNER_ON_MISMATCH`) is one field on
/// this struct, not a positional-argument expansion across every test site.
///
/// Tests typically use `Default` + field updates:
///
/// ```ignore
/// OverrideSources {
///     pm: SourceValue { cli: Some("yarn"), env: None },
///     ..OverrideSources::default()
/// }
/// ```
///
/// Production goes through `ResolutionOverrides::from_cli_and_env`,
/// which builds this from process state.
#[derive(Debug, Default)]
pub(crate) struct OverrideSources<'a> {
    /// `--pm` flag value plus `RUNNER_PM` env.
    pub pm: SourceValue<'a>,
    /// `--runner` flag value plus `RUNNER_RUNNER` env.
    pub runner: SourceValue<'a>,
    /// `--fallback` flag value plus `RUNNER_FALLBACK` env.
    pub fallback: SourceValue<'a>,
    /// `--on-mismatch` flag value plus `RUNNER_ON_MISMATCH` env.
    pub on_mismatch: SourceValue<'a>,
    /// `--no-warnings` flag presence plus `RUNNER_NO_WARNINGS` env.
    pub no_warnings: ExplainSource<'a>,
    /// `-q`/`--quiet` flag presence plus `RUNNER_QUIET` env.
    pub quiet: ExplainSource<'a>,
    /// `--explain` flag presence plus `RUNNER_EXPLAIN` env.
    pub explain: ExplainSource<'a>,
    /// `-k`/`--keep-going` flag presence plus `RUNNER_KEEP_GOING` env.
    pub keep_going: ExplainSource<'a>,
    /// `--kill-on-fail` flag presence plus `RUNNER_KILL_ON_FAIL` env.
    pub kill_on_fail: ExplainSource<'a>,
    /// `RUNNER_INSTALL_PMS` env (comma/space-separated). No CLI flag; the
    /// config side comes from the loaded `runner.toml` `[install].pms`.
    pub install_pms: SourceValue<'a>,
    /// `RUNNER_INSTALL_SCRIPTS` env (`deny`|`allow`). The `cli` side stays
    /// unused here — the `--no-scripts`/`--scripts` flags are layered on at the
    /// dispatch boundary; the config side comes from `[install].scripts`.
    pub install_scripts: SourceValue<'a>,
    /// Internal `RUNNER_GROUP_ACTIVE` nesting marker a parent runner set on
    /// this process (see `crate::cmd::GROUP_ACTIVE_ENV`). Env-only — no CLI or
    /// config side — but captured here so `from_sources` stays a pure function
    /// of its inputs and tests can inject it.
    pub group_active: Option<&'a str>,
    /// Loaded `runner.toml` if any.
    pub config: Option<&'a LoadedConfig>,
}

/// CLI flag plus env-var value for a string-typed override. The
/// resolver trims and de-duplicates these per the precedence chain in
/// `parse_override` (CLI wins; whitespace-only values count as unset).
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct SourceValue<'a> {
    /// CLI flag value, if the user passed one.
    pub cli: Option<&'a str>,
    /// Env-var value, if set.
    pub env: Option<&'a str>,
}

/// CLI-side diagnostic flags (`--no-warnings`, `--quiet`, `--explain`)
/// bundled into a single struct so `ResolutionOverrides::from_cli_and_env`
/// stays under clippy's argument/bool thresholds.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct DiagnosticFlags {
    /// `--no-warnings` flag presence (CLI side only — env handled inside
    /// `from_cli_and_env`).
    pub no_warnings: bool,
    /// `-q`/`--quiet` flag presence (CLI side only — env handled inside
    /// `from_cli_and_env`).
    pub quiet: bool,
    /// `--explain` flag presence (CLI side only — env handled inside
    /// `from_cli_and_env`).
    pub explain: bool,
}

/// CLI flag (presence) plus env-var value for a boolean-typed override
/// like `--explain` / `RUNNER_EXPLAIN`. CLI wins; env is interpreted by
/// `super::policies::is_env_truthy`.
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct ExplainSource<'a> {
    /// `true` when the CLI flag was passed.
    pub cli: bool,
    /// Env-var value, if set.
    pub env: Option<&'a str>,
}

impl ResolvedPm {
    /// Render a one-line description of the chain step that produced this
    /// decision. Used by `--explain` to attribute the PM choice.
    pub(crate) fn describe(&self) -> String {
        match &self.via {
            ResolutionStep::Override(origin) => {
                format!("{} {}", self.pm.label(), origin.describe_pm_source())
            }
            ResolutionStep::ManifestPackageManager => {
                format!("{} via package.json \"packageManager\"", self.pm.label())
            }
            ResolutionStep::ManifestDevEngines { on_fail } => format!(
                "{} via package.json \"devEngines.packageManager\" (onFail={on_fail:?})",
                self.pm.label(),
            ),
            ResolutionStep::Lockfile => {
                format!("{} via detected lockfile", self.pm.label())
            }
            ResolutionStep::PathProbe { binary } => {
                format!("{} via PATH probe at {}", self.pm.label(), binary.display())
            }
            ResolutionStep::LegacyNpmFallback => {
                format!("{} via --fallback=npm (legacy)", self.pm.label())
            }
        }
    }
}