diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dd991527..319400ce 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,7 +7,7 @@ jobs: 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/checkout@v6, with: { sparse-checkout: schemas/*.schema.json, sparse-checkout-cone-mode: false } }, { 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 4cbf194f..fc9652e1 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,7 +1,7 @@ { "formatter": [ { "language_server": { "name": "dprint" } }, - { "external": { "command": "dprint", "arguments": ["fmt", "--stdin", "{buffer_path}"] } } + { "external": { "command": "dprint", "arguments": ["fmt", "--stdin", "{buffer_path}"] } }, ], "prettier": { "allowed": false }, "lsp": { @@ -10,26 +10,54 @@ "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" } - ] - } - } + { + "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.example.json", "doctor.v3.example.json"], + "url": "./schemas/doctor.v3.schema.json", + }, + { + "fileMatch": ["schemas/why.v3.example.json", "why.v3.example.json"], + "url": "./schemas/why.v3.schema.json", + }, + ], + }, + }, }, "rust-analyzer": { "initialization_options": { "diagnostics": { "experimental": { "enable": true } }, "check": { "workspace": true, "allTargets": true, "features": "all" }, - "cargo": { "allTargets": true, "features": ["man", "schema", "run"] } - } - } + "cargo": { "allTargets": true, "features": ["man", "schema", "run"] }, + }, + }, }, "file_types": { "SVG": ["*.ico"], "Shell Script": [".runner-dev", "justfile"] }, - "languages": { "TOML": { "language_servers": ["tombi", "..."] } } + "languages": { "TOML": { "language_servers": ["tombi", "..."] } }, } diff --git a/CHANGELOG.md b/CHANGELOG.md index 400d3d2f..29918391 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,86 @@ 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`. +## [0.13.1] - 2026-06-14 + +### Added + +- `runner doctor --json` schema **v3** (now the default for `doctor`): + the flat detection dump becomes a structured diagnostic inventory — + `invocation`/`environment`/`runner` provenance, per-`ecosystems` + decisions with a `confidence` grade derived from the resolution step + (override/manifest/lockfile → high, PATH probe → medium, legacy npm + fallback → low, failure → none), task `sources` as first-class objects, + `fqn`-keyed `tasks` with effective `resolved` commands, PATH-probed + `tools`, duplicate-task-name `conflicts` (which task wins, which are + shadowed, and why), flattened `diagnostics`, and a self-describing + `resolution` policy block. Implements the former `doctor.v3-draft` + schema; the real output validates against both the committed + `doctor.v3.schema.json` and the original draft. Draft shapes nothing + can emit yet (rich dependency edges, workspace identity, probe errors) + are deferred, not declared. v1/v2 remain available via + `--schema-version`; human output is unchanged. +- `runner why --json` schema **v3** (now the default for `why`): the + report is restructured around `{task, match}` candidate pairs plus a + `decision` block. Each task carries a stable identity + (`fqn` = `root:#`, `provider`, `kind` — cargo aliases are + now labeled `cargo-alias`), its origin (`source` file, + `source_pointer` key path), and resolution data (`definition`, + `resolved` command preview, `cwd`, sibling `aliases`, + `dependencies`). The `match` half exposes the exact run-time selection + key (`source_priority`, `depth`, `display_order`, alias-last), and + `decision.strategy` names the branch taken (`single-candidate`, + `ranked`, `filtered`, `exec-fallback`). Implements the former + `why.v3-draft` example, which the real output now reproduces verbatim; + v1/v2 stay available via `--schema-version`. `list` remains at v2 — its + v3 draft is still under review, and it rejects `--schema-version 3` + rather than mislabel output. `schema --all` emits the committed + `schemas/why.v3.schema.json`, and the example validates against it. +- Both v3 schemas use the `:#` fqn form, with `#` + separating the structured prefix from the verbatim task name so a name + containing `:` (e.g. an npm script `fmt:update`) stays unambiguous. +- Deno tasks now run without the `deno` binary. A `deno.json` / + `deno.jsonc` task whose command is a leaf shell command executes + in-process via the embedded `deno_task_shell` (deno's own cross-platform + task shell) when `deno` isn't on `PATH`; with `deno` installed it still + shells out to `deno task` for full fidelity. The `unstable-deno-exec` + feature flips the default to self-exec-first. Tasks that invoke `deno` + themselves or declare `dependencies` still need the binary. The shell + engine lives in a reusable `tool::shell` so other shell-string task + sources can build on it later. +- Deno task descriptions. The object form + (`"build": { "command": "…", "description": "…" }`) is now parsed and + the description surfaces in `runner list` / `why` / `doctor`, alongside + the existing bare-string form. +- `runner list` and the bare `runner` view now print a duplicate-name + conflict footer. When two sources define the same task name (e.g. a + `just` `run` recipe and `cargo run`), it names the source that + `runner run ` actually dispatches and the ones it shadows — using + the same precedence as dispatch — so a silently shadowed task no longer + goes unnoticed. + +### Changed + +- Cargo built-in aliases now fold under their canonical subcommand in + `runner list` and the bare `runner` view. `b`/`c`/`d`/`t`/`r`/`rm` are + shown as aliases of the promoted `build`/`check`/`doc`/`test`/`run`/ + `remove` tasks (e.g. `test (t)`) instead of standing alone; both the + canonical name and the short form still dispatch. Aliases that carry + extra arguments (`bb`, `cl`, `rq`, …) keep their own rows. Promoting + `run`/`remove` can collide with a same-named `just`/other task — that + collision now surfaces in the conflict footer above rather than hiding. +- `runner doctor --json` (v3) now probes package-manager and task-runner + versions via ` --version` (previously only the Node runtime + carried a version), reports a per-task `self_executable` flag (true for + deno tasks runner can run through the embedded shell), and derives the + Deno tool's `required` from it. Node is included in `ecosystems` / + `tools` whenever a resolver or task signal implies it, not only when a + Node package manager was lockfile-detected. +- The committed v3 schemas (`doctor.v3.schema.json`, + `why.v3.schema.json`) set `additionalProperties: false` throughout, so + validation catches stray or misspelled fields in real output instead of + silently accepting them. + ## [0.13.0] - 2026-06-12 ### Added @@ -1145,7 +1225,8 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - `run` alias binary for shorter invocation. - Unified commands for task run/list, dependency install, clean, and exec. -[Unreleased]: https://github.com/kjanat/runner/compare/v0.13.0...HEAD +[Unreleased]: https://github.com/kjanat/runner/compare/v0.13.1...HEAD +[0.13.1]: https://github.com/kjanat/runner/compare/v0.13.0...v0.13.1 [0.13.0]: https://github.com/kjanat/runner/compare/v0.12.2...v0.13.0 [0.12.2]: https://github.com/kjanat/runner/compare/v0.12.1...v0.12.2 [0.12.1]: https://github.com/kjanat/runner/compare/v0.12.0...v0.12.1 diff --git a/Cargo.lock b/Cargo.lock index 33f4df7a..bef8027f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,12 +76,24 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.6.1" @@ -160,6 +172,72 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "deno_error" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3007d3f1ea92ea503324ae15883aac0c2de2b8cf6fead62203ff6a67161007ab" +dependencies = [ + "deno_error_macro", + "libc", + "url", +] + +[[package]] +name = "deno_error_macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b565e60a9685cdf312c888665b5f8647ac692a7da7e058a5e2268a466da8eaf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "deno_path_util" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c7e98943f0d068928906db0c7bde89de684fa32c6a8018caacc4cee2cdd72b" +dependencies = [ + "deno_error", + "percent-encoding", + "sys_traits", + "thiserror", + "url", +] + +[[package]] +name = "deno_task_shell" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd069c87eb3cdc25e9898fe633a07ec7f20218e92ad3e7eccab3db324764d4fb" +dependencies = [ + "bitflags", + "deno_path_util", + "futures", + "glob", + "monch", + "nix", + "path-dedot", + "sys_traits", + "thiserror", + "tokio", + "which", + "windows-sys 0.59.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -197,6 +275,109 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "hashbrown" version = "0.16.1" @@ -227,6 +408,109 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -277,6 +561,16 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "junction" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +dependencies = [ + "scopeguard", + "windows-sys 0.61.2", +] + [[package]] name = "libc" version = "0.2.186" @@ -289,18 +583,89 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "memchr" version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "monch" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bcc6ad3b93f756f2532d29f7c7291b8d246a2c460a99a3611327bb726830014" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "path-dedot" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07ba0ad7e047712414213ff67533e6dd477af0a4e1d14fb52343e53d30ea9397" +dependencies = [ + "once_cell", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -347,7 +712,7 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" [[package]] name = "runner-run" -version = "0.13.0" +version = "0.13.1" dependencies = [ "actions-rs", "anyhow", @@ -355,6 +720,7 @@ dependencies = [ "clap_complete", "clap_mangen", "colored", + "deno_task_shell", "json-schema-sort", "json5", "schemars", @@ -363,6 +729,7 @@ dependencies = [ "serde_json", "shlex 2.0.1", "terminal_size", + "tokio", "toml", "unicode-width", "yaml-rust2", @@ -406,6 +773,12 @@ dependencies = [ "syn", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -488,6 +861,34 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -505,6 +906,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sys_traits" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88826d6169418c98e8bc52a43f79d50907dfa3e87ba5161930d42c6f980b35ee" +dependencies = [ + "junction", + "libc", + "sys_traits_macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "sys_traits_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "181f22127402abcf8ee5c83ccd5b408933fec36a6095cf82cda545634692657e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -515,6 +950,62 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -564,25 +1055,64 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "which" +version = "8.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -594,6 +1124,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -601,58 +1147,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -665,6 +1259,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + [[package]] name = "yaml-rust2" version = "0.11.0" @@ -676,6 +1276,83 @@ dependencies = [ "hashlink", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index c1894889..9a47ada4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "runner-run" -version = "0.13.0" +version = "0.13.1" edition = "2024" rust-version = "1.95" description = "Universal project task runner" @@ -46,16 +46,17 @@ path = "src/bin/run.rs" required-features = ["run"] [dependencies] -actions-rs = "0.1" -anyhow = "1" -colored = "3" -json5 = "1" -semver = "1" -serde_json = "1" -shlex = "2" -terminal_size = "0.4" -unicode-width = "0.2" -yaml-rust2 = "0.11" +actions-rs = "0.1" +anyhow = "1" +colored = "3" +deno_task_shell = "0.33" +json5 = "1" +semver = "1" +serde_json = "1" +shlex = "2" +terminal_size = "0.4" +unicode-width = "0.2" +yaml-rust2 = "0.11" [dependencies.clap] version = "4" @@ -82,6 +83,10 @@ optional = true version = "1" features = ["derive"] +[dependencies.tokio] +version = "1" +features = ["io-util", "process", "rt", "signal", "time"] + [dependencies.toml] version = "1.1" default-features = false @@ -97,10 +102,14 @@ default-features = false features = ["parse", "serde"] [features] -default = ["run"] -man = ["dep:clap_mangen"] -run = [] -schema = ["dep:json-schema-sort", "dep:schemars"] +default = ["run"] +man = ["dep:clap_mangen"] +run = [] +schema = ["dep:json-schema-sort", "dep:schemars"] +# Flip the deno self-exec policy to self-exec-first (default policy is +# `deno task` when deno is present, self-exec only as fallback). The +# self-exec engine itself is always compiled in. +unstable-deno-exec = [] [lints.clippy] all = { level = "deny", priority = -1 } diff --git a/schemas/doctor.v1.example.json b/schemas/doctor.v1.example.json index 68e8a8a8..17a39095 100644 --- a/schemas/doctor.v1.example.json +++ b/schemas/doctor.v1.example.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "root": "/home/kjanat/projects/runner", + "root": "/path/to/project", "ecosystems": [ "node", "rust" @@ -14,7 +14,7 @@ "just" ], "node_version": null, - "current_node": "24.14.1", + "current_node": "24.0.0", "monorepo": true }, "overrides": { @@ -33,24 +33,21 @@ "manifest_pm": { "pm": "bun", "source": "packageManager", - "version": "1.3.14", + "version": "1.0.0", "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" + "bun": "/usr/local/bin/bun", + "npm": "/opt/volta/bin/npm", + "pnpm": null, + "yarn": "/opt/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" + "resolved": "/opt/volta/tools/image/npm/10.0.0/bin/npm" }, "yarn": { - "resolved": "/home/kjanat/.volta/tools/image/yarn/1.22.22/bin/yarn" + "resolved": null } } } diff --git a/schemas/doctor.v2.example.json b/schemas/doctor.v2.example.json index 0c283e88..952f5816 100644 --- a/schemas/doctor.v2.example.json +++ b/schemas/doctor.v2.example.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "root": "/home/kjanat/projects/runner", + "root": "/path/to/project", "ecosystems": [ "node", "rust" @@ -14,7 +14,7 @@ "just" ], "node_version": null, - "current_node": "24.14.1", + "current_node": "24.0.0", "monorepo": true }, "overrides": { @@ -33,24 +33,21 @@ "manifest_pm": { "pm": "bun", "source": "packageManager", - "version": "1.3.14", + "version": "1.0.0", "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" + "bun": "/usr/local/bin/bun", + "npm": "/opt/volta/bin/npm", + "pnpm": null, + "yarn": "/opt/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" + "resolved": "/opt/volta/tools/image/npm/10.0.0/bin/npm" }, "yarn": { - "resolved": "/home/kjanat/.volta/tools/image/yarn/1.22.22/bin/yarn" + "resolved": null } } } diff --git a/schemas/doctor.v2.schema.json b/schemas/doctor.v2.schema.json index e9dc8f18..5c17acb0 100644 --- a/schemas/doctor.v2.schema.json +++ b/schemas/doctor.v2.schema.json @@ -402,7 +402,7 @@ } }, "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.", + "description": "JSON schema for the v2 `runner doctor --json` document. v2 uses tool-name task source labels.", "type": "object", "required": [ "decisions", diff --git a/schemas/doctor.v3-draft.schema.json b/schemas/doctor.v3-draft.schema.json deleted file mode 100644 index 058d8ef5..00000000 --- a/schemas/doctor.v3-draft.schema.json +++ /dev/null @@ -1,880 +0,0 @@ -{ - "$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/doctor.v3.example.json b/schemas/doctor.v3.example.json new file mode 100644 index 00000000..64920836 --- /dev/null +++ b/schemas/doctor.v3.example.json @@ -0,0 +1,785 @@ +{ + "$schema": "https://kjanat.github.io/schemas/doctor.v3.schema.json", + "schema_version": 3, + "kind": "runner.doctor", + "invocation": { + "argv": [ + "runner", + "doctor", + "--json" + ], + "cwd": "/path/to/project", + "started_at": "2026-01-01T00:00:00Z" + }, + "environment": { + "arch": "x86_64", + "os": "linux", + "path_entries": [ + "/usr/local/bin", + "/usr/bin" + ], + "shell": "bash" + }, + "runner": { + "binary": "/usr/local/bin/runner", + "name": "runner", + "version": "0.13.1", + "schema_versions": { + "doctor": 3, + "list": 2, + "why": 3 + } + }, + "project": { + "monorepo": true, + "root": "/path/to/project", + "root_source": "/path/to/project", + "workspace": null + }, + "overrides": { + "explain": false, + "fallback": "probe", + "no_warnings": false, + "on_mismatch": "warn", + "pm": null, + "pm_by_ecosystem": {}, + "prefer_runners": [], + "runner": null + }, + "ecosystems": [ + { + "decision": { + "confidence": "high", + "reason": "bun via package.json \"packageManager\"", + "selected": "bun" + }, + "name": "node", + "root": "/path/to/project", + "selected_package_manager": "bun", + "signals": { + "lockfile_pm": "bun", + "manifest_pm": "bun", + "path_probe": { + "bun": "/usr/local/bin/bun", + "npm": "/opt/volta/bin/npm", + "pnpm": null, + "yarn": "/opt/volta/bin/yarn" + }, + "shims": { + "npm": { + "manager": "volta", + "resolved": "/opt/volta/tools/image/npm/10.0.0/bin/npm" + }, + "yarn": { + "manager": "volta", + "resolved": null + } + } + } + }, + { + "decision": { + "confidence": "high", + "reason": "detected via cargo project signal", + "selected": "cargo" + }, + "name": "rust", + "root": "/path/to/project", + "selected_package_manager": "cargo", + "signals": { + "package_managers": [ + "cargo" + ] + } + } + ], + "sources": [ + { + "exists": true, + "id": "src:root:package.json", + "kind": "package.json", + "package": null, + "path": "/path/to/project/package.json", + "relpath": "package.json", + "scope": "root", + "task_pointer": "scripts" + }, + { + "exists": true, + "id": "src:root:just", + "kind": "just", + "package": null, + "path": "/path/to/project/justfile", + "relpath": "justfile", + "scope": "root", + "task_pointer": null + }, + { + "exists": true, + "id": "src:root:cargo-alias", + "kind": "cargo-alias", + "package": null, + "path": "/path/to/project/.cargo/config.toml", + "relpath": ".cargo/config.toml", + "scope": "root", + "task_pointer": "alias" + } + ], + "tasks": [ + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:package.json#fmt", + "is_alias": false, + "name": "fmt", + "resolved": "bun run fmt", + "self_executable": false, + "source": "/path/to/project/package.json", + "source_pointer": "scripts.fmt" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:package.json#fmt:update", + "is_alias": false, + "name": "fmt:update", + "resolved": "bun run fmt:update", + "self_executable": false, + "source": "/path/to/project/package.json", + "source_pointer": "scripts.fmt:update" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:package.json#typecheck", + "is_alias": false, + "name": "typecheck", + "resolved": "bun run typecheck", + "self_executable": false, + "source": "/path/to/project/package.json", + "source_pointer": "scripts.typecheck" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just#build-packages", + "is_alias": false, + "name": "build-packages", + "resolved": "just build-packages", + "self_executable": false, + "source": "/path/to/project/justfile", + "source_pointer": "build-packages" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just#default", + "is_alias": false, + "name": "default", + "resolved": "just default", + "self_executable": false, + "source": "/path/to/project/justfile", + "source_pointer": "default" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/", + "fqn": "root:just#gen-schema", + "is_alias": false, + "name": "gen-schema", + "resolved": "just gen-schema", + "self_executable": false, + "source": "/path/to/project/justfile", + "source_pointer": "gen-schema" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just#ls", + "is_alias": false, + "name": "ls", + "resolved": "just ls", + "self_executable": false, + "source": "/path/to/project/justfile", + "source_pointer": "ls" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just#run", + "is_alias": false, + "name": "run", + "resolved": "just run", + "self_executable": false, + "source": "/path/to/project/justfile", + "source_pointer": "run" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just#runner", + "is_alias": false, + "name": "runner", + "resolved": "just runner", + "self_executable": false, + "source": "/path/to/project/justfile", + "source_pointer": "runner" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": "Build release bin and verify the facade shims spawn the native binary.", + "fqn": "root:just#test-release", + "is_alias": false, + "name": "test-release", + "resolved": "just test-release", + "self_executable": false, + "source": "/path/to/project/justfile", + "source_pointer": "test-release" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "build", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#b", + "is_alias": true, + "name": "b", + "resolved": "cargo build", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.b" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "build --bin run --bin runner", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#bb", + "is_alias": true, + "name": "bb", + "resolved": "cargo build --bin run --bin runner", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.bb" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "build --bin run --bin runner --release", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#bbr", + "is_alias": true, + "name": "bbr", + "resolved": "cargo build --bin run --bin runner --release", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.bbr" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin run", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#bin-run", + "is_alias": true, + "name": "bin-run", + "resolved": "cargo run --quiet --bin run", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.bin-run" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin runner", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#bin-runner", + "is_alias": true, + "name": "bin-runner", + "resolved": "cargo run --quiet --bin runner", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.bin-runner" + }, + { + "aliases": [ + "b" + ], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#build", + "is_alias": false, + "name": "build", + "resolved": "cargo build", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.build" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "check", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#c", + "is_alias": true, + "name": "c", + "resolved": "cargo check", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.c" + }, + { + "aliases": [ + "c" + ], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#check", + "is_alias": false, + "name": "check", + "resolved": "cargo check", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.check" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "clippy --all-targets --all-features", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#cl", + "is_alias": true, + "name": "cl", + "resolved": "cargo clippy --all-targets --all-features", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.cl" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin runner -- completions", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#comp", + "is_alias": true, + "name": "comp", + "resolved": "cargo run --quiet --bin runner -- completions", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.comp" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "doc", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#d", + "is_alias": true, + "name": "d", + "resolved": "cargo doc", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.d" + }, + { + "aliases": [ + "d" + ], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#doc", + "is_alias": false, + "name": "doc", + "resolved": "cargo doc", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.doc" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin run -- --pm npm dprint fmt", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#f", + "is_alias": true, + "name": "f", + "resolved": "cargo run --quiet --bin run -- --pm npm dprint fmt", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.f" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin run -- --pm npm dprint fmt", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#format", + "is_alias": true, + "name": "format", + "resolved": "cargo run --quiet --bin run -- --pm npm dprint fmt", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.format" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "install --path .", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#i", + "is_alias": true, + "name": "i", + "resolved": "cargo install --path .", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.i" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "clippy --all-targets --all-features -- -D warnings -D clippy::all", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#l", + "is_alias": true, + "name": "l", + "resolved": "cargo clippy --all-targets --all-features -- -D warnings -D clippy::all", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.l" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "clippy --all-targets --all-features -- -D warnings -D clippy::all", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#lint", + "is_alias": true, + "name": "lint", + "resolved": "cargo clippy --all-targets --all-features -- -D warnings -D clippy::all", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.lint" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --features man -- man", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#man", + "is_alias": true, + "name": "man", + "resolved": "cargo run --quiet --features man -- man", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.man" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "metadata --format-version 1", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#meta", + "is_alias": true, + "name": "meta", + "resolved": "cargo metadata --format-version 1", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.meta" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#r", + "is_alias": true, + "name": "r", + "resolved": "cargo run", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.r" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin run --release", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#rbin-run", + "is_alias": true, + "name": "rbin-run", + "resolved": "cargo run --quiet --bin run --release", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rbin-run" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin runner --release", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#rbin-runner", + "is_alias": true, + "name": "rbin-runner", + "resolved": "cargo run --quiet --bin runner --release", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rbin-runner" + }, + { + "aliases": [ + "rm" + ], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#remove", + "is_alias": false, + "name": "remove", + "resolved": "cargo remove", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.remove" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "remove", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#rm", + "is_alias": true, + "name": "rm", + "resolved": "cargo remove", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rm" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#rq", + "is_alias": true, + "name": "rq", + "resolved": "cargo run --quiet", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rq" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --release", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#rr", + "is_alias": true, + "name": "rr", + "resolved": "cargo run --release", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rr" + }, + { + "aliases": [ + "r" + ], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#run", + "is_alias": false, + "name": "run", + "resolved": "cargo run", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.run" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin runner", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#runner", + "is_alias": true, + "name": "runner", + "resolved": "cargo run --quiet --bin runner", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.runner" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --features schema -- schema", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#schema", + "is_alias": true, + "name": "schema", + "resolved": "cargo run --quiet --features schema -- schema", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.schema" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "test", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#t", + "is_alias": true, + "name": "t", + "resolved": "cargo test", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.t" + }, + { + "aliases": [ + "t" + ], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias#test", + "is_alias": false, + "name": "test", + "resolved": "cargo test", + "self_executable": false, + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.test" + } + ], + "tools": [ + { + "id": "tool:runtime:node", + "kind": "runtime", + "name": "node", + "probe": { + "status": "found", + "path": "/usr/bin/node", + "version": "24.0.0" + }, + "required": true + }, + { + "id": "tool:package-manager:bun", + "kind": "package-manager", + "name": "bun", + "probe": { + "status": "found", + "path": "/usr/bin/bun", + "version": "1.1.0" + }, + "required": true + }, + { + "id": "tool:package-manager:cargo", + "kind": "package-manager", + "name": "cargo", + "probe": { + "status": "found", + "path": "/usr/bin/cargo", + "version": "1.83.0" + }, + "required": true + }, + { + "id": "tool:task-runner:just", + "kind": "task-runner", + "name": "just", + "probe": { + "status": "found", + "path": "/usr/bin/just", + "version": "1.36.0" + }, + "required": true + } + ], + "conflicts": [ + { + "kind": "duplicate-task-name", + "reason": "2 sources define `runner`; lowest (source_priority=2, source_depth=0, display_order=2, alias-last) key wins", + "selected": "root:just#runner", + "selector": "runner", + "severity": "info", + "shadowed": [ + "root:cargo-alias#runner" + ] + } + ], + "diagnostics": [], + "resolution": { + "fqn_policy": "exact-only", + "precedence": [ + "source-priority", + "source-depth", + "display-order", + "alias-last" + ], + "short_name_policy": "deterministic-precedence" + } +} diff --git a/schemas/doctor.v3.schema.json b/schemas/doctor.v3.schema.json new file mode 100644 index 00000000..43acf962 --- /dev/null +++ b/schemas/doctor.v3.schema.json @@ -0,0 +1,698 @@ +{ + "$id": "https://kjanat.github.io/schemas/doctor.v3.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "ConfidenceV3": { + "description": "How sure the resolver is about an ecosystem's PM selection.", + "oneOf": [ + { + "description": "Explicit signal: override, manifest declaration, or lockfile.", + "type": "string", + "const": "high" + }, + { + "description": "Inferred: PATH probe found a usable binary.", + "type": "string", + "const": "medium" + }, + { + "description": "Legacy `--fallback npm` default with no signal at all.", + "type": "string", + "const": "low" + }, + { + "description": "Resolution failed.", + "type": "string", + "const": "none" + } + ] + }, + "ConflictV3": { + "description": "A task name claimed by more than one source: who wins, who is shadowed.", + "type": "object", + "required": [ + "kind", + "reason", + "selected", + "selector", + "severity", + "shadowed" + ], + "properties": { + "kind": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "selected": { + "description": "FQN of the winning task.", + "type": "string" + }, + "selector": { + "type": "string" + }, + "severity": { + "$ref": "#/$defs/SeverityV3" + }, + "shadowed": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + }, + "DependencyKindV3": { + "description": "What kind of thing a probed tool is. The draft's `binary` /\n`package-binary` kinds join when something probes them.", + "type": "string", + "enum": [ + "runtime", + "package-manager", + "task-runner" + ] + }, + "DiagnosticV3": { + "description": "One detection/resolution diagnostic, flattened from the warning\nstreams.", + "type": "object", + "required": [ + "code", + "message", + "severity", + "source", + "task" + ], + "properties": { + "code": { + "description": "Stable warning category (the warning's source subsystem).", + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/$defs/SeverityV3" + }, + "source": { + "type": [ + "null", + "string" + ] + }, + "task": { + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "DoctorTaskV3": { + "description": "One task in the doctor inventory. Same identity scheme as `why` v3\n(`fqn`, `source_pointer`, `aliases`, `definition`, `resolved`).", + "type": "object", + "required": [ + "aliases", + "cwd", + "definition", + "dependencies", + "description", + "fqn", + "is_alias", + "name", + "resolved", + "self_executable", + "source", + "source_pointer" + ], + "properties": { + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "definition": { + "type": [ + "null", + "string" + ] + }, + "dependencies": { + "description": "Task dependencies. Always empty today: no extractor records dependency edges yet; the edge shape lands with the first extractor.", + "type": "array", + "items": true + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "fqn": { + "type": "string" + }, + "is_alias": { + "description": "True when this task is an alias for another target; `definition` holds the target it expands to (e.g. cargo `b` → `build`).", + "type": "boolean" + }, + "name": { + "type": "string" + }, + "resolved": { + "description": "Effective command preview. Null when it depends on a PM resolution that failed.", + "type": [ + "null", + "string" + ] + }, + "self_executable": { + "description": "True when runner can run this task without its source's primary tool. Only deno tasks runner can execute via the embedded task shell (leaf command, no `dependencies`, no `deno` invocation) qualify today; all other sources are false.", + "type": "boolean" + }, + "source": { + "type": [ + "null", + "string" + ] + }, + "source_pointer": { + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "EcosystemDecisionV3": { + "type": "object", + "required": [ + "confidence", + "reason", + "selected" + ], + "properties": { + "confidence": { + "$ref": "#/$defs/ConfidenceV3" + }, + "reason": { + "type": "string" + }, + "selected": { + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "EcosystemV3": { + "description": "One detected ecosystem and the PM decision made for it.", + "type": "object", + "required": [ + "decision", + "name", + "root", + "selected_package_manager", + "signals" + ], + "properties": { + "decision": { + "$ref": "#/$defs/EcosystemDecisionV3" + }, + "name": { + "type": "string" + }, + "root": { + "type": "string" + }, + "selected_package_manager": { + "type": [ + "null", + "string" + ] + }, + "signals": { + "description": "Detection evidence. Node carries the full signal set (lockfile/manifest/PATH probe/shim classification, keyed by tool with the shim manager as data); other ecosystems list their detected package managers." + } + }, + "additionalProperties": false + }, + "EnvironmentV3": { + "description": "Host facts that influence probing and dispatch.", + "type": "object", + "required": [ + "arch", + "os", + "path_entries", + "shell" + ], + "properties": { + "arch": { + "type": "string" + }, + "os": { + "type": "string" + }, + "path_entries": { + "type": "array", + "items": { + "type": "string" + } + }, + "shell": { + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "InvocationV3": { + "description": "How this report came to be: the exact process invocation.", + "type": "object", + "required": [ + "argv", + "cwd", + "started_at" + ], + "properties": { + "argv": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "started_at": { + "description": "UTC RFC 3339 timestamp of report generation.", + "type": "string" + } + }, + "additionalProperties": false + }, + "OverridesV3": { + "description": "Effective override stack, labels only. Provenance (cli/env/config)\nstays on the v2 surface.", + "type": "object", + "required": [ + "explain", + "fallback", + "no_warnings", + "on_mismatch", + "pm", + "pm_by_ecosystem", + "prefer_runners", + "runner" + ], + "properties": { + "explain": { + "type": "boolean" + }, + "fallback": { + "type": "string" + }, + "no_warnings": { + "type": "boolean" + }, + "on_mismatch": { + "type": "string" + }, + "pm": { + "type": [ + "null", + "string" + ] + }, + "pm_by_ecosystem": { + "type": "object", + "additionalProperties": { + "type": [ + "null", + "string" + ] + } + }, + "prefer_runners": { + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "ProjectInfoV3": { + "description": "Project anchoring facts.", + "type": "object", + "required": [ + "monorepo", + "root", + "root_source", + "workspace" + ], + "properties": { + "monorepo": { + "type": "boolean" + }, + "root": { + "type": "string" + }, + "root_source": { + "description": "What anchored root detection. Currently always the root itself (cwd or --dir); a dedicated anchor model is future work.", + "type": "string" + }, + "workspace": { + "description": "Workspace identity. Always null today: workspace kind/root detection is not yet modeled (the monorepo flag is the coarse signal)." + } + }, + "additionalProperties": false + }, + "ResolutionPolicyV3": { + "description": "Self-description of the task-selection policy, so consumers don't\nhardcode runner's precedence rules.", + "type": "object", + "required": [ + "fqn_policy", + "precedence", + "short_name_policy" + ], + "properties": { + "fqn_policy": { + "type": "string" + }, + "precedence": { + "type": "array", + "items": { + "type": "string" + } + }, + "short_name_policy": { + "type": "string" + } + }, + "additionalProperties": false + }, + "RunnerInfoV3": { + "description": "The reporting binary's own identity and contract versions.", + "type": "object", + "required": [ + "binary", + "name", + "schema_versions", + "version" + ], + "properties": { + "binary": { + "type": "string" + }, + "name": { + "type": "string" + }, + "schema_versions": { + "$ref": "#/$defs/SchemaVersionsV3" + }, + "version": { + "type": "string" + } + }, + "additionalProperties": false + }, + "SchemaVersionsV3": { + "description": "Latest schema version each `--json` surface speaks.", + "type": "object", + "required": [ + "doctor", + "list", + "why" + ], + "properties": { + "doctor": { + "type": "integer", + "minimum": 0, + "format": "uint32" + }, + "list": { + "type": "integer", + "minimum": 0, + "format": "uint32" + }, + "why": { + "type": "integer", + "minimum": 0, + "format": "uint32" + } + }, + "additionalProperties": false + }, + "SeverityV3": { + "description": "Severity of a conflict or diagnostic. The draft's `debug`/`error`\nlevels join when something emits them.", + "type": "string", + "enum": [ + "info", + "warning" + ] + }, + "SourceV3": { + "description": "One task-source config file as a first-class object.", + "type": "object", + "required": [ + "exists", + "id", + "kind", + "package", + "path", + "relpath", + "scope", + "task_pointer" + ], + "properties": { + "exists": { + "type": "boolean" + }, + "id": { + "description": "Stable source identity: `src::`.", + "type": "string" + }, + "kind": { + "$ref": "#/$defs/TaskSourceLabel" + }, + "package": { + "description": "Package identity for manifest-backed sources. Null today." + }, + "path": { + "type": "string" + }, + "relpath": { + "type": "string" + }, + "scope": { + "description": "Project-root-relative scope; `root` until member scoping lands.", + "type": "string" + }, + "task_pointer": { + "description": "Key of the container holding tasks inside the file (`scripts`, `tasks`, `alias`, …); null for flat-format files.", + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo-alias", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + }, + "ToolProbeV3": { + "description": "PATH-probe outcome, tagged by `status`.", + "oneOf": [ + { + "type": "object", + "required": [ + "path", + "status", + "version" + ], + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string", + "const": "found" + }, + "version": { + "description": "Resolved version: taken from detection when known, otherwise read by running ` --version`. Null when the binary reports no parseable version.", + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "const": "missing" + } + }, + "additionalProperties": false + } + ] + }, + "ToolV3": { + "description": "One PATH-probed tool the project relies on.", + "type": "object", + "required": [ + "id", + "kind", + "name", + "probe", + "required" + ], + "properties": { + "id": { + "description": "Stable tool identity: `tool::`.", + "type": "string" + }, + "kind": { + "$ref": "#/$defs/DependencyKindV3" + }, + "name": { + "type": "string" + }, + "probe": { + "$ref": "#/$defs/ToolProbeV3" + }, + "required": { + "type": "boolean" + } + }, + "additionalProperties": false + } + }, + "title": "runner doctor --json --schema-version 3", + "description": "JSON schema for the current v3 `runner doctor --json` document: structured diagnostic inventory with invocation/environment provenance, per-ecosystem decisions, sources, fqn-keyed tasks, tools, conflicts, and diagnostics.", + "type": "object", + "required": [ + "$schema", + "conflicts", + "diagnostics", + "ecosystems", + "environment", + "invocation", + "kind", + "overrides", + "project", + "resolution", + "runner", + "schema_version", + "sources", + "tasks", + "tools" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/$defs/ConflictV3" + } + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/DiagnosticV3" + } + }, + "ecosystems": { + "type": "array", + "items": { + "$ref": "#/$defs/EcosystemV3" + } + }, + "environment": { + "$ref": "#/$defs/EnvironmentV3" + }, + "invocation": { + "$ref": "#/$defs/InvocationV3" + }, + "kind": { + "description": "Payload discriminator; always \"runner.doctor\".", + "type": "string" + }, + "overrides": { + "$ref": "#/$defs/OverridesV3" + }, + "project": { + "$ref": "#/$defs/ProjectInfoV3" + }, + "resolution": { + "$ref": "#/$defs/ResolutionPolicyV3" + }, + "runner": { + "$ref": "#/$defs/RunnerInfoV3" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 3, + "minimum": 0, + "format": "uint32" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/SourceV3" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/$defs/DoctorTaskV3" + } + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/$defs/ToolV3" + } + } + }, + "additionalProperties": false +} diff --git a/schemas/list.v1.example.json b/schemas/list.v1.example.json index 7b9475eb..f56c1cf1 100644 --- a/schemas/list.v1.example.json +++ b/schemas/list.v1.example.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "root": "/home/kjanat/projects/runner", + "root": "/path/to/project", "tasks": [ { "name": "fmt", diff --git a/schemas/list.v2.example.json b/schemas/list.v2.example.json index 139e3ea1..8d37b562 100644 --- a/schemas/list.v2.example.json +++ b/schemas/list.v2.example.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "root": "/home/kjanat/projects/runner", + "root": "/path/to/project", "tasks": [ { "name": "fmt", diff --git a/schemas/why.v1.example.json b/schemas/why.v1.example.json index f791bf5b..182b1afc 100644 --- a/schemas/why.v1.example.json +++ b/schemas/why.v1.example.json @@ -11,7 +11,7 @@ "alias_of": "test", "description": null, "passthrough_to": null, - "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + "source_dir": "/path/to/project/.cargo/config.toml" } ], "selected": { @@ -23,7 +23,7 @@ "alias_of": "test", "description": null, "passthrough_to": null, - "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + "source_dir": "/path/to/project/.cargo/config.toml" }, "pm_resolution": null } diff --git a/schemas/why.v2.example.json b/schemas/why.v2.example.json index 363b1d0d..fac81d0d 100644 --- a/schemas/why.v2.example.json +++ b/schemas/why.v2.example.json @@ -11,7 +11,7 @@ "alias_of": "test", "description": null, "passthrough_to": null, - "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + "source_dir": "/path/to/project/.cargo/config.toml" } ], "selected": { @@ -23,7 +23,7 @@ "alias_of": "test", "description": null, "passthrough_to": null, - "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + "source_dir": "/path/to/project/.cargo/config.toml" }, "pm_resolution": null } diff --git a/schemas/why.v3-draft.example.json b/schemas/why.v3-draft.example.json deleted file mode 100644 index bc028e02..00000000 --- a/schemas/why.v3-draft.example.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "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/schemas/why.v3.example.json b/schemas/why.v3.example.json new file mode 100644 index 00000000..fc00159b --- /dev/null +++ b/schemas/why.v3.example.json @@ -0,0 +1,63 @@ +{ + "schema_version": 3, + "kind": "runner.why", + "root": "/path/to/project", + "query": "t", + "pm_resolution": null, + "selected": { + "task": { + "name": "t", + "fqn": "root:cargo-alias#t", + "provider": "cargo", + "kind": "cargo-alias", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.t", + "description": null, + "aliases": [], + "definition": "test", + "resolved": "cargo test", + "cwd": "/path/to/project", + "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": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.t", + "description": null, + "aliases": [], + "definition": "test", + "resolved": "cargo test", + "cwd": "/path/to/project", + "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/schemas/why.v3.schema.json b/schemas/why.v3.schema.json new file mode 100644 index 00000000..81e45937 --- /dev/null +++ b/schemas/why.v3.schema.json @@ -0,0 +1,327 @@ +{ + "$id": "https://kjanat.github.io/schemas/why.v3.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" + } + } + } + ] + }, + "ProviderLabel": { + "type": "string", + "enum": [ + "node", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "python" + ] + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo-alias", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + }, + "WhyCandidateV3": { + "description": "One candidate: the task's identity plus how it matched the query.", + "type": "object", + "required": [ + "match", + "task" + ], + "properties": { + "match": { + "$ref": "#/$defs/WhyMatchV3" + }, + "task": { + "$ref": "#/$defs/WhyTaskV3" + } + }, + "additionalProperties": false + }, + "WhyDecisionV3": { + "type": "object", + "required": [ + "reason", + "strategy" + ], + "properties": { + "reason": { + "type": "string" + }, + "strategy": { + "description": "Selection branch taken: `single-candidate`, `ranked`, `filtered`, or `exec-fallback`.", + "type": "string" + } + }, + "additionalProperties": false + }, + "WhyMatchV3": { + "type": "object", + "required": [ + "depth", + "display_order", + "is_alias", + "matched_by", + "passthrough_to", + "selector", + "source_priority" + ], + "properties": { + "depth": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "format": "uint" + }, + "display_order": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "format": "uint8" + }, + "is_alias": { + "type": "boolean" + }, + "matched_by": { + "description": "How the selector matched. `why` matches exact names only today.", + "type": "string" + }, + "passthrough_to": { + "type": [ + "null", + "string" + ] + }, + "selector": { + "type": "string" + }, + "source_priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "format": "uint16" + } + }, + "additionalProperties": false + }, + "WhyTaskV3": { + "type": "object", + "required": [ + "aliases", + "cwd", + "definition", + "dependencies", + "description", + "fqn", + "kind", + "name", + "provider", + "resolved", + "source", + "source_pointer" + ], + "properties": { + "aliases": { + "description": "Names of sibling alias tasks that resolve to this task.", + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "definition": { + "description": "Raw definition target: alias expansion or tool-specific run target.", + "type": [ + "null", + "string" + ] + }, + "dependencies": { + "description": "Task dependencies. Always empty today: no extractor records dependency edges yet.", + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "fqn": { + "description": "Stable task identity: `:#`. The `#` boundary keeps a task name containing `:` (e.g. `fmt:update`) unambiguous. Scope is `root` until workspace-member scoping lands.", + "type": "string" + }, + "kind": { + "$ref": "#/$defs/TaskSourceLabel" + }, + "name": { + "type": "string" + }, + "provider": { + "$ref": "#/$defs/ProviderLabel" + }, + "resolved": { + "description": "Effective command preview. Null when it depends on a PM resolution that was not performed for this candidate.", + "type": [ + "null", + "string" + ] + }, + "source": { + "description": "Config file the task was extracted from, when resolvable.", + "type": [ + "null", + "string" + ] + }, + "source_pointer": { + "description": "Locator inside the source file: a key path for structured configs (`alias.t`, `scripts.test`), the target/recipe name for flat files.", + "type": [ + "null", + "string" + ] + } + }, + "additionalProperties": false + }, + "WhyWarning": { + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + }, + "title": "runner why --json --schema-version 3", + "description": "JSON schema for `runner why --json --schema-version 3`.", + "type": "object", + "required": [ + "candidates", + "decision", + "kind", + "pm_resolution", + "query", + "root", + "schema_version", + "selected" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyCandidateV3" + } + }, + "decision": { + "$ref": "#/$defs/WhyDecisionV3" + }, + "kind": { + "description": "Payload discriminator; always \"runner.why\".", + "type": "string" + }, + "pm_resolution": { + "anyOf": [ + { + "$ref": "#/$defs/PmResolution" + }, + { + "type": "null" + } + ] + }, + "query": { + "description": "The task selector as the user typed it.", + "type": "string" + }, + "root": { + "description": "Project root the query ran against.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 3, + "minimum": 0, + "format": "uint32" + }, + "selected": { + "anyOf": [ + { + "$ref": "#/$defs/WhyCandidateV3" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false +} diff --git a/src/chain/exec.rs b/src/chain/exec.rs index 1dc749e6..cd778ece 100644 --- a/src/chain/exec.rs +++ b/src/chain/exec.rs @@ -13,16 +13,10 @@ use crate::types::{DetectionWarning, ProjectContext}; /// Dispatch a chain. Returns the first-observed failing task's exit /// code, or 0 if every task succeeded. /// -/// "First-observed" means first in *detection* order, not necessarily -/// first by wall-clock completion: sequential mode short-circuits on -/// the first non-zero exit (so detection order == completion order); -/// parallel mode polls children every 50ms and records the first -/// non-zero code seen during a poll window. When multiple parallel -/// siblings finish inside the same 50ms window, the recorded code -/// follows `remaining` iteration (i.e. spawn) order. True -/// completion-time ordering would need an OS-level termination -/// timestamp (waitpid + rusage on Linux) which the std crate doesn't -/// surface — out of scope for v1. +/// "First-observed" is detection order, not wall-clock completion: +/// sequential mode short-circuits on the first non-zero exit; parallel +/// mode polls children and records the first non-zero code seen, with +/// ties within a poll window broken by spawn order. /// /// Per-task resolver warnings are collected into a shared `HashSet` /// so the user sees each unique warning once, not N times. @@ -124,12 +118,9 @@ fn run_parallel_streaming( let colorize = colored::control::SHOULD_COLORIZE.should_colorize(); // Synchronous sink: each reader thread writes lines directly to - // stdout/stderr, acquiring the underlying lock per line. The old - // design ran a dedicated writer thread that held the stdio locks - // across an mpsc drain, which deadlocked against `eprintln!` calls - // on the main thread (the `→ ` arrow inside - // `dispatch_task_piped`). A sink keeps every emit point on the - // caller's thread and bounds lock duration to one `writeln!`. + // stdout/stderr, taking the lock per line. Bounding lock duration to + // one `writeln!` avoids deadlocking against `eprintln!` on the main + // thread (the `→ ` arrow in `dispatch_task_piped`). let sink: Arc = Arc::new(StdioSink); // Spawn each task with piped stdio and start reader threads. diff --git a/src/cli.rs b/src/cli.rs index 394d0fab..174c1e48 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -845,19 +845,23 @@ pub(crate) struct GlobalOpts { pub no_warnings: bool, /// Pin the JSON output schema to a specific version. Defaults to the - /// latest version this binary produces. The chosen version controls + /// latest version the command produces. The chosen version controls /// the `source` field on tasks/decisions in `doctor`/`list`/`why` /// JSON output. v1 uses filename-style labels (`"justfile"`, `"bacon.toml"`), - /// v2 uses tool names (`"just"`, `"bacon"`). The resolver, human output, - /// and qualified-task parsing are unaffected. + /// v2 uses tool names (`"just"`, `"bacon"`). v3 restructures the + /// reports: `why` gains `{task, match}` candidate pairs plus a + /// decision block, `doctor` becomes a structured diagnostic + /// inventory; `list` rejects v3 until its contract lands. The + /// resolver, human output, and qualified-task parsing are unaffected. #[arg( long = "schema-version", global = true, - value_parser = clap::value_parser!(u32).range(1..=2), + value_parser = clap::value_parser!(u32).range(1..=3), value_name = "N", help = concat!( - "Pin JSON output schema version (", - cyan!("1"), " or ", cyan!("2"), "). Defaults to latest. Affects ", + "Pin JSON output schema version (doctor/why: ", + cyan!("1"), "-", cyan!("3"), ", list: ", + cyan!("1"), "-", cyan!("2"), "). Defaults to latest. Affects ", cyan!("--json"), " output of doctor/list/why only." ), )] diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index 6abb6891..7e1afdac 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -21,6 +21,11 @@ use crate::resolver::ResolutionOverrides; use crate::schema::Project; use crate::types::ProjectContext; +/// Flat `Project` schema version the human doctor renderer was written +/// against. Pinned explicitly so the non-JSON path can't silently drift +/// if [`crate::schema::CURRENT_VERSION`] bumps for the `list` surface. +const DOCTOR_HUMAN_PROJECT_SCHEMA_VERSION: u32 = 2; + /// Print a full diagnostic dump of the resolver's view of `ctx`. /// /// # Errors @@ -34,7 +39,23 @@ pub(crate) fn doctor( json: bool, schema_version: u32, ) -> Result<()> { - let project = Project::build_with_schema(ctx, overrides, schema_version, true); + if json && schema_version >= 3 { + // v3 restructured the whole document; v1/v2 keep the flat + // `Project` shape below. + let report = crate::schema::doctor_v3::DoctorReportV3::build(ctx, overrides, true); + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + // The human renderer still reads the flat v2 `Project` shape, so a + // non-JSON call always builds at the v2 contract regardless of the + // requested (or defaulted) version. + let build_version = if json { + schema_version + } else { + DOCTOR_HUMAN_PROJECT_SCHEMA_VERSION + }; + let project = Project::build_with_schema(ctx, overrides, build_version, true); if json { println!("{}", serde_json::to_string_pretty(&project)?); diff --git a/src/cmd/info.rs b/src/cmd/info.rs index 546e21d8..702cffc2 100644 --- a/src/cmd/info.rs +++ b/src/cmd/info.rs @@ -7,7 +7,7 @@ use std::io::IsTerminal; use anyhow::Result; use colored::Colorize; -use super::list::print_tasks_grouped; +use super::list::{print_conflicts, print_tasks_grouped}; use crate::resolver::ResolutionOverrides; use crate::schema::Project; use crate::types::{ProjectContext, version_matches}; @@ -93,6 +93,7 @@ pub(crate) fn info( + 1; // blank separator before the task list let refs: Vec<&crate::types::Task> = ctx.tasks.iter().collect(); print_tasks_grouped(&refs, &ctx.root, banner_rows); + print_conflicts(ctx, overrides); } Ok(()) } diff --git a/src/cmd/list.rs b/src/cmd/list.rs index 6e9fce78..0fc55290 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -76,6 +76,7 @@ pub(crate) fn list( // compact path is reserved for the bare `runner` / `runner // info` glance view (see `print_tasks_grouped`). print_tasks_grouped_with_mode(&filtered, &ctx.root, RenderMode::Rich); + print_conflicts(ctx, overrides); } Ok(()) } @@ -153,6 +154,86 @@ pub(super) fn print_tasks_grouped(tasks: &[&Task], root: &Path, reserved_rows: u print_tasks_grouped_with_mode(tasks, root, select_render_mode(tasks, reserved_rows)); } +/// Print duplicate-name conflicts beneath the task list so a shadowed +/// task — one the bare-name lookup silently will *not* run (e.g. `cargo +/// run` losing to `just run`) — doesn't go unnoticed. Resolution uses the +/// same precedence as `runner run`, so the named winner is what actually +/// executes. No output when there are no conflicts. +pub(super) fn print_conflicts(ctx: &ProjectContext, overrides: &ResolutionOverrides) { + if let Some(report) = format_conflicts(ctx, overrides, std::io::stdout().is_terminal()) { + print!("{report}"); + } +} + +/// Render the duplicate-name conflict footer, or `None` when there are +/// none. Leading blank line + trailing newline so callers can append it +/// verbatim after the task list. +fn format_conflicts( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, + stdout_is_terminal: bool, +) -> Option { + use std::collections::BTreeMap; + + let mut by_name: BTreeMap<&str, Vec<&Task>> = BTreeMap::new(); + for task in &ctx.tasks { + by_name.entry(task.name.as_str()).or_default().push(task); + } + + let conflicts: Vec<(String, &'static str, Vec<&'static str>)> = by_name + .into_iter() + .filter_map(|(name, group)| { + // A single source never duplicates a name; only cross-source + // collisions are conflicts. + if group.iter().map(|t| t.source).collect::>().len() < 2 { + return None; + } + let winner = crate::cmd::run::select_task_entry(ctx, overrides, &group); + let mut shadowed: Vec<&'static str> = group + .iter() + .filter(|t| t.source != winner.source) + .map(|t| t.source.label()) + .collect(); + shadowed.sort_unstable(); + shadowed.dedup(); + Some((name.to_string(), winner.source.label(), shadowed)) + }) + .collect(); + + if conflicts.is_empty() { + return None; + } + + let count = conflicts.len(); + let header = format!( + "{count} name conflict{} — `runner run ` picks one source:", + if count == 1 { "" } else { "s" } + ); + let mut out = String::from("\n"); + let _ = writeln!( + out, + " {}", + if stdout_is_terminal { + header.yellow().bold().to_string() + } else { + header + } + ); + for (name, winner, shadowed) in conflicts { + let line = format!("{name}: runs {winner}, shadows {}", shadowed.join(", ")); + let _ = writeln!( + out, + " {}", + if stdout_is_terminal { + line.dimmed().to_string() + } else { + line + } + ); + } + Some(out) +} + fn print_tasks_grouped_with_mode(tasks: &[&Task], root: &Path, mode: RenderMode) { let stdout_is_terminal = std::io::stdout().is_terminal(); print!( @@ -209,12 +290,19 @@ fn render_tasks_grouped_rich( let label = source_label(source, root, stdout_is_terminal); let label_width = padded_column_width(source.label(), SOURCE_COL_WIDTH); - for task in source_tasks { - let value = task.alias_of.as_deref().or(task.description.as_deref()); + for (task, alias_names) in fold_aliases(&source_tasks) { + // A folded canonical carries its aliases in the name cell; a + // standalone alias keeps showing its target in the value cell. + let name = name_with_aliases(&task.name, &alias_names); + let value = if alias_names.is_empty() { + task.alias_of.as_deref().or(task.description.as_deref()) + } else { + task.description.as_deref() + }; out.push_str(&render_rich_row( &label, label_width, - &task.name, + &name, value, stdout_is_terminal, terminal_width, @@ -387,7 +475,10 @@ fn render_tasks_grouped_compact(tasks: &[&Task], stdout_is_terminal: bool) -> St if source_tasks.is_empty() { continue; } - let names: Vec<&str> = source_tasks.iter().map(|task| task.name.as_str()).collect(); + let names: Vec = fold_aliases(&source_tasks) + .iter() + .map(|(task, alias_names)| name_with_aliases(&task.name, alias_names)) + .collect(); let label = compact_source_label(source, stdout_is_terminal); let _ = writeln!(out, " {label}{}", names.join(", ")); } @@ -404,6 +495,46 @@ fn tasks_for_source<'a>(tasks: &[&'a Task], source: TaskSource) -> Vec<&'a Task> source_tasks } +/// Fold rename-aliases into their canonical sibling: a task whose +/// `alias_of` names another task in the same group gets no row of its +/// own; instead its name is attached to that target. Returns each +/// surfaced task with its sorted alias names. Preserves input order. +fn fold_aliases<'a>(source_tasks: &[&'a Task]) -> Vec<(&'a Task, Vec<&'a str>)> { + use std::collections::{HashMap, HashSet}; + + let names: HashSet<&str> = source_tasks.iter().map(|t| t.name.as_str()).collect(); + let mut aliases: HashMap<&str, Vec<&'a str>> = HashMap::new(); + let mut folded: HashSet<&str> = HashSet::new(); + for task in source_tasks { + if let Some(target) = task.alias_of.as_deref() + && target != task.name + && names.contains(target) + { + aliases.entry(target).or_default().push(task.name.as_str()); + folded.insert(task.name.as_str()); + } + } + + source_tasks + .iter() + .filter(|task| !folded.contains(task.name.as_str())) + .map(|task| { + let mut names = aliases.remove(task.name.as_str()).unwrap_or_default(); + names.sort_unstable(); + (*task, names) + }) + .collect() +} + +/// Canonical task name with any folded aliases appended as `name (a, b)`. +fn name_with_aliases(name: &str, aliases: &[&str]) -> String { + if aliases.is_empty() { + name.to_string() + } else { + format!("{name} ({})", aliases.join(", ")) + } +} + fn compact_source_label(source: TaskSource, stdout_is_terminal: bool) -> String { let label = pad_visible( source.label(), @@ -541,8 +672,9 @@ mod tests { use std::path::{Path, PathBuf}; use super::{ - RenderMode, expected_source_labels, file_uri, render_rich_row, render_tasks_grouped, - render_tasks_grouped_rich, select_render_mode_for, source_label, source_path, + RenderMode, expected_source_labels, file_uri, format_conflicts, render_rich_row, + render_tasks_grouped, render_tasks_grouped_rich, select_render_mode_for, source_label, + source_path, }; use crate::resolver::ResolutionOverrides; use crate::schema::CURRENT_VERSION; @@ -700,6 +832,64 @@ mod tests { } } + fn ctx_with_tasks(tasks: Vec) -> ProjectContext { + ProjectContext { + root: PathBuf::from("/tmp/conflicts"), + package_managers: Vec::new(), + task_runners: Vec::new(), + tasks, + node_version: None, + current_node: None, + is_monorepo: false, + warnings: Vec::new(), + } + } + + #[test] + fn format_conflicts_flags_cross_source_shadowing() { + let ctx = ctx_with_tasks(vec![ + task("run", TaskSource::Justfile), + task("run", TaskSource::CargoAliases), + task("build", TaskSource::Justfile), // single source → not a conflict + ]); + + let report = format_conflicts(&ctx, &ResolutionOverrides::default(), false) + .expect("`run` is defined by two sources"); + + assert!(report.contains("1 name conflict"), "got: {report}"); + assert!(report.contains("run: runs"), "got: {report}"); + assert!(report.contains("shadows cargo"), "got: {report}"); + assert!( + !report.contains("build:"), + "single-source task is not a conflict" + ); + } + + #[test] + fn format_conflicts_returns_none_without_collisions() { + let ctx = ctx_with_tasks(vec![ + task("build", TaskSource::Justfile), + task("test", TaskSource::CargoAliases), + ]); + assert!(format_conflicts(&ctx, &ResolutionOverrides::default(), false).is_none()); + } + + #[test] + fn fold_groups_rename_alias_under_canonical_sibling() { + let mut tasks = [ + task("build", TaskSource::CargoAliases), + task("b", TaskSource::CargoAliases), + task("lint", TaskSource::CargoAliases), + ]; + tasks[1].alias_of = Some("build".into()); // b → build (sibling) folds + tasks[2].alias_of = Some("clippy --all".into()); // not a sibling → standalone + let refs: Vec<&Task> = tasks.iter().collect(); + + let rendered = render_tasks_grouped(&refs, Path::new("."), RenderMode::Compact, false); + + assert_eq!(rendered, " cargo build (b), lint\n"); + } + #[test] fn compact_mode_emits_one_line_per_source() { let mut tasks = [ diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 2f35cc2a..d9d75be5 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -60,19 +60,14 @@ fn node_bin_dirs(dir: &Path) -> Vec { /// Prepend the project's `node_modules/.bin` dirs to the child's `PATH`. /// -/// `npm run` / `pnpm run` / `bun run` do this for `package.json` scripts, -/// but tasks runner spawns *directly* — `turbo run ` for -/// `turbo.json` entries, the bare-binary exec fallback — inherited the -/// shell's `PATH` unchanged, so a devDependency-only binary died with -/// ENOENT unless it also happened to be installed globally. The OS-level -/// bare-name lookup honors a `PATH` set on the [`Command`] itself -/// (documented on [`Command::new`]), so prepending here fixes both the +/// Node PMs inject this for `package.json` scripts, but runner spawns +/// tasks directly (`turbo run `, the bare-binary fallback), so a +/// devDependency-only binary would die with ENOENT. The OS honors a +/// `PATH` set on the [`Command`] itself, so prepending fixes both the /// spawn and anything the task launches in turn. /// -/// Entries already present in the parent `PATH` are not deduplicated: -/// prepending unconditionally is what gives local bins priority over -/// global installs, matching the Node PMs (nested `npm run` invocations -/// stack duplicates the same way). +/// Entries are not deduplicated against the parent `PATH`: prepending +/// unconditionally gives local bins priority over global installs. fn prepend_node_bin_path(command: &mut Command, dir: &Path) { let bins = node_bin_dirs(dir); if bins.is_empty() { @@ -96,17 +91,13 @@ fn prepended_path(bins: &[PathBuf], parent: Option<&OsStr>) -> Option /// Re-resolve a bare program name against the project's bin dirs. /// /// [`crate::tool::program::command`] resolves bare names against the -/// *parent* `PATH` × `PATHEXT` at build time — before this module gets a -/// chance to prepend the bin dirs — and the child-`PATH` search the -/// standard library performs at spawn time only appends `.exe`, so a -/// `turbo.cmd`/`.ps1` shim that exists only under `node_modules/.bin` -/// would still fail to spawn. When the (still-bare) name resolves inside -/// `bins`, rebuild the command around the absolute shim path, preserving -/// args and env tweaks (e.g. bacon's `COLUMNS`). Absolute/relative -/// programs and parent-`PATH` hits are left alone — which also means a -/// global install currently shadows a local one on Windows, the reverse -/// of the Unix precedence; fixing that would require resolution order to -/// live inside `tool::program` where the project root isn't known. +/// parent `PATH`×`PATHEXT` before the bin dirs are prepended, and the +/// std child-`PATH` search only appends `.exe` at spawn time — so a +/// `turbo.cmd`/`.ps1` shim living only under `node_modules/.bin` would +/// fail to spawn. When a bare name resolves inside `bins`, rebuild the +/// command around the absolute shim path, preserving args and env. +/// Absolute/relative programs and parent-`PATH` hits are left alone +/// (so a global install still shadows a local one here, unlike Unix). #[cfg(windows)] fn resolve_program_in_bins(command: &mut Command, bins: &[PathBuf]) { let program = command.get_program().to_os_string(); diff --git a/src/cmd/run.rs b/src/cmd/run.rs index ffe2cfad..c0c17c5f 100644 --- a/src/cmd/run.rs +++ b/src/cmd/run.rs @@ -49,13 +49,16 @@ pub(crate) fn run( args: &[String], sink: super::WarningSink<'_>, ) -> Result { - let mut cmd = dispatch::resolve_dispatch(ctx, overrides, task, args, sink)?; + let dispatch = dispatch::resolve_dispatch(ctx, overrides, task, args, sink, true)?; // Wrap the child's output in a collapsible GitHub Actions group // (`runner: `) when enabled. Opened after resolution so the `→` // dispatch arrow stays visible above the fold and a resolver error // never leaves an empty group; the guard closes the group on drop. let _group = super::task_group(overrides, task); - Ok(super::exit_code(cmd.status()?)) + match dispatch { + dispatch::Dispatch::Spawn(mut cmd) => Ok(super::exit_code(cmd.status()?)), + dispatch::Dispatch::DenoSelfExec(self_exec) => self_exec.run(), + } } /// Resolve `task` and spawn it with piped stdout/stderr (so the caller @@ -72,11 +75,19 @@ pub(crate) fn dispatch_task_piped( ) -> Result { use std::process::Stdio; - let mut cmd = dispatch::resolve_dispatch(ctx, overrides, task, args, sink)?; - cmd.stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - Ok(cmd.spawn()?) + // Chain mode disables deno self-exec (in-process execution can't be + // piped/spawned as a child), so resolution always yields a Command. + match dispatch::resolve_dispatch(ctx, overrides, task, args, sink, false)? { + dispatch::Dispatch::Spawn(mut cmd) => { + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(cmd.spawn()?) + } + dispatch::Dispatch::DenoSelfExec(_) => { + anyhow::bail!("internal: deno self-exec is not available in chain mode") + } + } } #[cfg(test)] mod tests { @@ -291,10 +302,8 @@ mod tests { #[test] fn bun_test_fallback_suppressed_when_resolver_returns_non_bun() { - // Models `--pm npm` against a Bun-detected project: the - // resolver returns Npm (override wins), so the fallback must - // not fire. The previous-shape "user intent wins" test now - // collapses to a simpler assertion about the resolved verdict. + // `--pm npm` against a Bun-detected project: the resolver + // returns Npm (override wins), so the fallback must not fire. let ctx = context(vec![PackageManager::Bun], vec![]); assert!(!should_use_bun_test_fallback( @@ -316,8 +325,8 @@ mod tests { #[test] fn bun_test_fallback_enabled_when_resolver_picks_bun_with_no_lockfile() { - // Models `--pm bun` against an empty ctx — resolver returns - // Bun even though ctx has no detected PM. Fallback fires. + // `--pm bun` against an empty ctx: resolver returns Bun despite + // no detected PM, so the fallback fires. let ctx = context(vec![], vec![]); assert!(should_use_bun_test_fallback( @@ -329,11 +338,9 @@ mod tests { #[test] fn source_depth_walks_upward_for_non_node_sources() { - // Generalization landed in the same change: depth-aware tiebreak - // used to require a custom upward walker per source. Now every - // source consults `tool::files::find_first_upwards`, so a - // Makefile two levels up still resolves with a finite depth (and - // therefore beats a hypothetical sibling resolved at MAX). + // Every source consults `tool::files::find_first_upwards`, so a + // Makefile two levels up resolves with a finite depth (and thus + // beats a hypothetical sibling resolved at MAX). let dir = TempDir::new("source-depth-upward"); let nested = dir.path().join("apps").join("api"); fs::create_dir_all(&nested).expect("nested dir should be created"); diff --git a/src/cmd/run/dispatch.rs b/src/cmd/run/dispatch.rs index 0434022c..cebf549d 100644 --- a/src/cmd/run/dispatch.rs +++ b/src/cmd/run/dispatch.rs @@ -22,8 +22,81 @@ use super::qualify::{ use super::select::select_task_entry; use crate::resolver::{OverrideOrigin, ResolutionOverrides, ResolveError, Resolver}; use crate::tool; +use crate::tool::deno_exec::DenoTaskPlan; use crate::types::{Ecosystem, PackageManager, ProjectContext, Task, TaskSource}; +/// Outcome of resolving a task: a spawnable process, or a deno task to +/// run in-process via the embedded task shell. +#[derive(Debug)] +pub(super) enum Dispatch { + /// A configured process to spawn (`.status()` / `.spawn()`). + Spawn(Command), + /// A deno task resolved for in-process execution (no `deno` binary). + DenoSelfExec(DenoSelfExec), +} + +/// A deno task resolved for in-process execution. +#[derive(Debug)] +pub(super) struct DenoSelfExec { + plan: DenoTaskPlan, + args: Vec, + cwd: std::path::PathBuf, +} + +impl DenoSelfExec { + /// Run the task in-process, returning its exit code. + pub(super) fn run(&self) -> Result { + tool::deno_exec::run(&self.plan, &self.args, &self.cwd) + } +} + +/// Whether a `deno` binary is resolvable on `$PATH`. +fn deno_present() -> bool { + let path = std::env::var_os("PATH").unwrap_or_default(); + let pathext = std::env::var_os("PATHEXT"); + crate::resolver::probe_path_for_doctor("deno", &path, pathext.as_deref()).is_some() +} + +/// Decide whether to run a deno `entry` in-process instead of spawning +/// `deno task`. Returns `Ok(Some(_))` to self-exec, `Ok(None)` to fall +/// through to `deno task`, or `Err` when deno is required (the task has +/// dependencies or invokes `deno`) but isn't installed. +/// +/// Default policy self-execs only as a fallback when deno is absent; the +/// `unstable-deno-exec` feature makes self-exec primary. +fn decide_deno_self_exec( + ctx: &ProjectContext, + entry: &Task, + args: &[String], + allow_self_exec: bool, +) -> Result> { + if entry.source != TaskSource::DenoJson { + return Ok(None); + } + let deno = deno_present(); + let self_exec_first = cfg!(feature = "unstable-deno-exec"); + if !allow_self_exec || (deno && !self_exec_first) { + return Ok(None); + } + + let plan = tool::deno::find_config_upwards(&ctx.root) + .and_then(|path| tool::deno_exec::plan(&path, &entry.name)); + match plan { + Some(plan) if plan.self_executable() => Ok(Some(DenoSelfExec { + plan, + args: args.to_vec(), + cwd: ctx.root.clone(), + })), + // Not self-executable: real deno can still run it; otherwise bail. + _ if deno => Ok(None), + _ => bail!( + "task {:?} needs deno (it has dependencies or invokes `deno`), \ + but deno is not installed", + entry.name + ), + } +} + /// Resolve `task` to a fully-configured [`Command`] without spawning it. /// /// Walks the same cascade for every caller — warning emission, qualified @@ -37,22 +110,19 @@ use crate::types::{Ecosystem, PackageManager, ProjectContext, Task, TaskSource}; /// lookups so a qualified miss like `runner run justfile:test` bails on /// the qualifier rather than silently dispatching `bun test`. /// -/// The resolver call lives inside the unqualified branch so qualified -/// misses don't pay for PM resolution (warning emission, potential -/// ` --version` spawn for devEngines.version checks) on an error -/// path they can't reach. Only the soft `NoSignalsFound { soft: true, -/// .. }` outcome collapses to `None` so the direct PATH spawn can still -/// fire for `runner run somebin`. Hard errors — `--fallback=error`, -/// manifest `onFail = Error`, and any other resolver failure — -/// propagate so the user sees the real diagnostic instead of a silent -/// degrade. +/// The resolver call sits inside the unqualified branch so qualified +/// misses skip PM resolution entirely. Only a soft `NoSignalsFound` +/// collapses to `None` (letting `runner run somebin` direct-spawn); +/// hard errors (`--fallback=error`, manifest `onFail = Error`, …) +/// propagate so the user sees the real diagnostic. pub(super) fn resolve_dispatch( ctx: &ProjectContext, overrides: &ResolutionOverrides, task: &str, args: &[String], mut sink: crate::cmd::WarningSink<'_>, -) -> Result { + allow_self_exec: bool, +) -> Result { crate::cmd::print_warnings(ctx, overrides, sink.as_deref_mut()); let (qualifier, task_name) = parse_qualified_task(task); @@ -134,7 +204,7 @@ pub(super) fn resolve_dispatch( ); let mut cmd = tool::bun::test_cmd(args); crate::cmd::configure_command(&mut cmd, &ctx.root); - return Ok(cmd); + return Ok(Dispatch::Spawn(cmd)); } // PM-exec fallback: dispatch through detected PM's exec primitive. @@ -147,7 +217,7 @@ pub(super) fn resolve_dispatch( args.join(" ").dimmed(), ); crate::cmd::configure_command(&mut cmd, &ctx.root); - return Ok(cmd); + return Ok(Dispatch::Spawn(cmd)); } bail!("task {task:?} not found. Run `runner list` to see available tasks."); @@ -163,6 +233,19 @@ pub(super) fn resolve_dispatch( select_task_entry(ctx, overrides, &restricted) }; + // Deno tasks may run in-process via the embedded task shell (no deno + // binary) per policy; otherwise fall through to `deno task`. + if let Some(self_exec) = decide_deno_self_exec(ctx, entry, args, allow_self_exec)? { + eprintln!( + "{} {} {} {}", + "→".dimmed(), + "deno-shell".dimmed(), + task_name.bold(), + args.join(" ").dimmed(), + ); + return Ok(Dispatch::DenoSelfExec(self_exec)); + } + eprintln!( "{} {} {} {}", "→".dimmed(), @@ -173,7 +256,7 @@ pub(super) fn resolve_dispatch( let mut cmd = build_run_command(ctx, overrides, entry, args, sink)?; crate::cmd::configure_command(&mut cmd, &ctx.root); - Ok(cmd) + Ok(Dispatch::Spawn(cmd)) } /// Build the command for the PM-exec fallback path. Used by both @@ -378,7 +461,9 @@ pub(crate) fn resolve_python_pm( mod tests { use std::path::PathBuf; - use super::{build_pm_exec_command, resolve_dispatch}; + use std::process::Command; + + use super::{Dispatch, build_pm_exec_command, resolve_dispatch}; use crate::resolver::ResolutionOverrides; use crate::types::{PackageManager, ProjectContext, Task, TaskRunner, TaskSource}; @@ -395,7 +480,14 @@ mod tests { } } - fn command_args(command: &std::process::Command) -> Vec { + fn expect_command(dispatch: Dispatch) -> Command { + match dispatch { + Dispatch::Spawn(command) => command, + Dispatch::DenoSelfExec(_) => panic!("expected a spawnable command, got deno self-exec"), + } + } + + fn command_args(command: &Command) -> Vec { command .get_args() .map(|arg| arg.to_string_lossy().into_owned()) @@ -409,7 +501,7 @@ mod tests { ..ResolutionOverrides::default() }; - let err = resolve_dispatch(&context(), &overrides, "lint:cargo", &[], None) + let err = resolve_dispatch(&context(), &overrides, "lint:cargo", &[], None, true) .expect_err("reversed qualifier should fail dispatch"); assert!(format!("{err:#}").contains("cargo:lint")); @@ -429,8 +521,17 @@ mod tests { }); let args = [String::from("--port"), String::from("3000")]; - let command = resolve_dispatch(&ctx, &ResolutionOverrides::default(), "serve", &args, None) - .expect("go package task should dispatch"); + let command = expect_command( + resolve_dispatch( + &ctx, + &ResolutionOverrides::default(), + "serve", + &args, + None, + true, + ) + .expect("go package task should dispatch"), + ); assert_eq!(command.get_program().to_string_lossy(), "go"); assert_eq!( @@ -453,14 +554,17 @@ mod tests { }); let args = [String::from("--flag")]; - let command = resolve_dispatch( - &ctx, - &ResolutionOverrides::default(), - "greenpy", - &args, - None, - ) - .expect("pyproject script should dispatch"); + let command = expect_command( + resolve_dispatch( + &ctx, + &ResolutionOverrides::default(), + "greenpy", + &args, + None, + true, + ) + .expect("pyproject script should dispatch"), + ); assert_eq!(command.get_program().to_string_lossy(), "uv"); assert_eq!(command_args(&command), ["run", "greenpy", "--flag"]); @@ -479,8 +583,17 @@ mod tests { passthrough_to: None, }); - let command = resolve_dispatch(&ctx, &ResolutionOverrides::default(), "greenpy", &[], None) - .expect("pyproject script should dispatch"); + let command = expect_command( + resolve_dispatch( + &ctx, + &ResolutionOverrides::default(), + "greenpy", + &[], + None, + true, + ) + .expect("pyproject script should dispatch"), + ); assert_eq!(command.get_program().to_string_lossy(), "poetry"); assert_eq!(command_args(&command), ["run", "greenpy"]); diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index c960e0e9..2403c8b7 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -57,6 +57,10 @@ fn schema_documents() -> Result> { filename: "doctor.v2.schema.json", value: output_schema::>("doctor", 2)?, }, + SchemaDocument { + filename: "doctor.v3.schema.json", + value: output_schema::>("doctor", 3)?, + }, SchemaDocument { filename: "list.v1.schema.json", value: output_schema::>("list", 1)?, @@ -73,6 +77,10 @@ fn schema_documents() -> Result> { filename: "why.v2.schema.json", value: output_schema::>("why", 2)?, }, + SchemaDocument { + filename: "why.v3.schema.json", + value: output_schema::>("why", 3)?, + }, ]) } @@ -144,33 +152,61 @@ fn patch_source_schema(schema: &mut Value, version: u32) { ); patch_task_info_source(defs); patch_why_candidate_source(defs); + patch_why_task_v3(defs); + patch_def_field(defs, "SourceV3", "kind", "TaskSourceLabel"); } fn patch_task_info_source(defs: &mut Map) { - patch_def_source(defs, "TaskInfo"); + patch_def_field(defs, "TaskInfo", "source", "TaskSourceLabel"); } fn patch_why_candidate_source(defs: &mut Map) { - patch_def_source(defs, "WhyCandidate"); + patch_def_field(defs, "WhyCandidate", "source", "TaskSourceLabel"); +} + +/// The v3 `why` task object splits the old `source` label into `kind` +/// (mechanism label) and `provider` (executing tool family); constrain +/// both to their closed label sets. +fn patch_why_task_v3(defs: &mut Map) { + if !defs.contains_key("WhyTaskV3") { + return; + } + defs.insert( + "ProviderLabel".to_string(), + json!({ "type": "string", "enum": PROVIDER_LABELS }), + ); + patch_def_field(defs, "WhyTaskV3", "kind", "TaskSourceLabel"); + patch_def_field(defs, "WhyTaskV3", "provider", "ProviderLabel"); } -fn patch_def_source(defs: &mut Map, def_name: &'static str) { - let Some(source_schema) = defs +fn patch_def_field( + defs: &mut Map, + def_name: &'static str, + field: &'static str, + target_def: &'static str, +) { + let Some(field_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")) + .and_then(|properties| properties.get_mut(field)) else { return; }; - *source_schema = json!({ "$ref": "#/$defs/TaskSourceLabel" }); + *field_schema = json!({ "$ref": format!("#/$defs/{target_def}") }); } fn task_source_label_schema(version: u32) -> Value { json!({ "type": "string", "enum": source_labels(version) }) } -fn source_labels(version: u32) -> &'static [&'static str] { +/// Closed set for the v3 `provider` field — the tool family that +/// executes the task. Mirrors `cmd::why::provider_label`. +const PROVIDER_LABELS: &[&str] = &[ + "node", "make", "just", "task", "turbo", "deno", "cargo", "go", "bacon", "mise", "python", +]; + +const fn source_labels(version: u32) -> &'static [&'static str] { match version { 1 => &[ "package.json", @@ -185,7 +221,7 @@ fn source_labels(version: u32) -> &'static [&'static str] { "mise.toml", "pyproject.toml", ], - _ => &[ + 2 => &[ "package.json", "make", "just", @@ -198,11 +234,24 @@ fn source_labels(version: u32) -> &'static [&'static str] { "mise", "pyproject.toml", ], + _ => &[ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo-alias", + "go", + "bacon", + "mise", + "pyproject.toml", + ], } } fn schema_id(command: &str, version: u32) -> String { - format!("https://kjanat.github.io/schemas/{command}.v{version}.schema.json") + crate::schema::schema_url(command, version) } fn title(command: &str, version: u32) -> String { @@ -215,7 +264,8 @@ fn title(command: &str, version: u32) -> String { 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(), + ("doctor", 2) => "JSON schema for the v2 `runner doctor --json` document. v2 uses tool-name task source labels.".to_string(), + ("doctor", _) => "JSON schema for the current v3 `runner doctor --json` document: structured diagnostic inventory with invocation/environment provenance, per-ecosystem decisions, sources, fqn-keyed tasks, tools, conflicts, and diagnostics.".to_string(), _ => format!("JSON schema for `{}`.", title(command, version)), } } diff --git a/src/cmd/why.rs b/src/cmd/why.rs index d97e1544..f15f9e3d 100644 --- a/src/cmd/why.rs +++ b/src/cmd/why.rs @@ -55,17 +55,27 @@ pub(crate) fn why( let pm_decision = pm_decision_for_selected(ctx, overrides, selected); - let report = build_report( - task, - &candidates, - selected, - pm_decision.as_ref(), - overrides, - ctx, - schema_version, - ); - - if json { + if json && schema_version >= 3 { + let report = build_report_v3( + task, + &candidates, + selected, + pm_decision.as_ref(), + overrides, + ctx, + schema_version, + ); + println!("{}", serde_json::to_string_pretty(&report)?); + } else if json { + let report = build_report( + task, + &candidates, + selected, + pm_decision.as_ref(), + overrides, + ctx, + schema_version, + ); println!("{}", serde_json::to_string_pretty(&report)?); } else { print_human( @@ -233,6 +243,332 @@ fn candidate_json<'a>( } } +/// `runner why --json --schema-version 3` payload. Field order mirrors +/// the committed `schemas/why.v3.example.json`. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +pub(super) struct WhyReportV3<'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, + #[cfg_attr( + feature = "schema", + schemars(description = "Payload discriminator; always \"runner.why\".") + )] + kind: &'static str, + #[cfg_attr( + feature = "schema", + schemars(description = "Project root the query ran against.") + )] + root: String, + #[cfg_attr( + feature = "schema", + schemars(description = "The task selector as the user typed it.") + )] + query: &'a str, + pm_resolution: Option, + selected: Option>, + candidates: Vec>, + decision: WhyDecisionV3, +} + +/// One candidate: the task's identity plus how it matched the query. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct WhyCandidateV3<'a> { + task: WhyTaskV3<'a>, + #[serde(rename = "match")] + matched: WhyMatchV3<'a>, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct WhyTaskV3<'a> { + name: &'a str, + #[cfg_attr( + feature = "schema", + schemars( + description = "Stable task identity: `:#`. The `#` boundary keeps a task name containing `:` (e.g. `fmt:update`) unambiguous. Scope is `root` until workspace-member scoping lands." + ) + )] + fqn: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "Tool family that would execute the task (e.g. `cargo`, `just`, `node`)." + ) + )] + provider: &'static str, + #[cfg_attr( + feature = "schema", + schemars(description = "Task mechanism label (v3 source label, e.g. `cargo-alias`).") + )] + kind: &'static str, + #[cfg_attr( + feature = "schema", + schemars(description = "Config file the task was extracted from, when resolvable.") + )] + source: Option, + #[cfg_attr( + feature = "schema", + schemars( + description = "Locator inside the source file: a key path for structured configs (`alias.t`, `scripts.test`), the target/recipe name for flat files." + ) + )] + source_pointer: Option, + description: Option<&'a str>, + #[cfg_attr( + feature = "schema", + schemars(description = "Names of sibling alias tasks that resolve to this task.") + )] + aliases: Vec<&'a str>, + #[cfg_attr( + feature = "schema", + schemars( + description = "Raw definition target: alias expansion or tool-specific run target." + ) + )] + definition: Option<&'a str>, + #[cfg_attr( + feature = "schema", + schemars( + description = "Effective command preview. Null when it depends on a PM resolution that was not performed for this candidate." + ) + )] + resolved: Option, + cwd: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "Task dependencies. Always empty today: no extractor records dependency edges yet." + ) + )] + dependencies: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct WhyMatchV3<'a> { + selector: &'a str, + #[cfg_attr( + feature = "schema", + schemars(description = "How the selector matched. `why` matches exact names only today.") + )] + matched_by: &'static str, + depth: Option, + display_order: u8, + source_priority: u16, + is_alias: bool, + passthrough_to: Option<&'static str>, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct WhyDecisionV3 { + #[cfg_attr( + feature = "schema", + schemars( + description = "Selection branch taken: `single-candidate`, `ranked`, `filtered`, or `exec-fallback`." + ) + )] + strategy: &'static str, + reason: String, +} + +fn build_report_v3<'a>( + query: &'a str, + candidates: &[&'a Task], + selected: Option<&'a Task>, + pm_decision: Option<&PmDecision>, + overrides: &ResolutionOverrides, + ctx: &'a ProjectContext, + schema_version: u32, +) -> WhyReportV3<'a> { + let candidate_v3 = |task: &'a Task| WhyCandidateV3 { + task: task_v3(task, ctx, pm_decision, selected, schema_version), + matched: match_v3(query, task, overrides, ctx), + }; + WhyReportV3 { + schema: String::new(), + schema_version, + kind: "runner.why", + root: ctx.root.display().to_string(), + query, + pm_resolution: pm_decision.map(pm_resolution), + selected: selected.map(candidate_v3), + candidates: candidates.iter().copied().map(candidate_v3).collect(), + decision: decision_v3(candidates, selected), + } +} + +fn task_v3<'a>( + task: &'a Task, + ctx: &'a ProjectContext, + pm_decision: Option<&PmDecision>, + selected: Option<&Task>, + schema_version: u32, +) -> WhyTaskV3<'a> { + let kind = crate::schema::labels::source_label_for(task.source, schema_version); + let is_selected = selected.is_some_and(|sel| std::ptr::eq(sel, task)); + WhyTaskV3 { + name: &task.name, + fqn: crate::schema::labels::fqn(task.source, &task.name, schema_version), + provider: provider_label(task.source), + kind, + source: source_dir_for_task(task, ctx).map(|path| path.display().to_string()), + source_pointer: source_pointer(task), + description: task.description.as_deref(), + aliases: ctx + .tasks + .iter() + .filter(|other| { + other.source == task.source && other.alias_of.as_deref() == Some(&task.name) + }) + .map(|other| other.name.as_str()) + .collect(), + definition: task.alias_of.as_deref().or(task.run_target.as_deref()), + resolved: resolved_command(task, pm_decision.filter(|_| is_selected)), + cwd: ctx.root.display().to_string(), + dependencies: Vec::new(), + } +} + +fn match_v3<'a>( + selector: &'a str, + task: &Task, + overrides: &ResolutionOverrides, + ctx: &ProjectContext, +) -> WhyMatchV3<'a> { + let depth = source_depth(ctx, task.source); + WhyMatchV3 { + selector, + matched_by: "name", + depth: (depth != usize::MAX).then_some(depth), + display_order: task.source.display_order(), + source_priority: source_priority(overrides, task.source), + is_alias: task.alias_of.is_some(), + passthrough_to: task.passthrough_to.map(crate::types::TaskRunner::label), + } +} + +fn decision_v3(candidates: &[&Task], selected: Option<&Task>) -> WhyDecisionV3 { + if candidates.is_empty() { + return WhyDecisionV3 { + strategy: "exec-fallback", + reason: "no task matched; `runner run` would route the name through the primary \ + package manager's exec primitive" + .to_string(), + }; + } + if selected.is_none() { + return WhyDecisionV3 { + strategy: "filtered", + reason: "every candidate was filtered out by --runner/RUNNER_RUNNER restrictions" + .to_string(), + }; + } + if candidates.len() == 1 { + return WhyDecisionV3 { + strategy: "single-candidate", + reason: "exact task name matched one candidate".to_string(), + }; + } + WhyDecisionV3 { + strategy: "ranked", + reason: format!( + "{} candidates; lowest (source_priority, source_depth, display_order, alias-last) \ + key wins", + candidates.len() + ), + } +} + +/// Tool family that executes tasks from this source. Distinct from the +/// v3 `kind` label, which names the extraction mechanism. +const fn provider_label(source: TaskSource) -> &'static str { + match source { + TaskSource::PackageJson => "node", + TaskSource::DenoJson => "deno", + TaskSource::TurboJson => "turbo", + TaskSource::Makefile => "make", + TaskSource::Justfile => "just", + TaskSource::Taskfile => "task", + TaskSource::CargoAliases => "cargo", + TaskSource::GoPackage => "go", + TaskSource::BaconToml => "bacon", + TaskSource::MiseToml => "mise", + TaskSource::PyprojectScripts => "python", + } +} + +/// Key path (structured configs) or target name (flat files) locating +/// the task inside its source file. +fn source_pointer(task: &Task) -> Option { + let name = &task.name; + match task.source { + TaskSource::CargoAliases => Some(format!("alias.{name}")), + TaskSource::PackageJson => Some(format!("scripts.{name}")), + TaskSource::DenoJson + | TaskSource::TurboJson + | TaskSource::Taskfile + | TaskSource::MiseToml => Some(format!("tasks.{name}")), + TaskSource::BaconToml => Some(format!("jobs.{name}")), + TaskSource::PyprojectScripts => Some(format!("project.scripts.{name}")), + TaskSource::Makefile | TaskSource::Justfile => Some(name.clone()), + TaskSource::GoPackage => None, + } +} + +/// Effective command preview for the candidate. Sources with a fixed +/// executing binary render deterministically; `package.json` and +/// `pyproject.toml` scripts depend on PM resolution, which `why` only +/// performs for the selected task — other candidates report null. +fn resolved_command(task: &Task, pm_decision: Option<&PmDecision>) -> Option { + let name = &task.name; + match task.source { + TaskSource::CargoAliases => Some(task.alias_of.as_deref().map_or_else( + || format!("cargo {name}"), + |expansion| format!("cargo {expansion}"), + )), + TaskSource::DenoJson => Some(format!("deno task {name}")), + TaskSource::TurboJson => Some(format!("turbo run {name}")), + TaskSource::Makefile => Some(format!("make {name}")), + TaskSource::Justfile => Some(format!("just {name}")), + TaskSource::Taskfile => Some(format!("task {name}")), + TaskSource::BaconToml => Some(format!("bacon {name}")), + TaskSource::MiseToml => Some(format!("mise run {name}")), + TaskSource::GoPackage => Some(format!( + "go run {target}", + target = task.run_target.as_deref().unwrap_or(name) + )), + TaskSource::PackageJson => match pm_decision { + Some(PmDecision::Node(Ok(decision))) => { + Some(format!("{pm} run {name}", pm = decision.pm.label())) + } + _ => None, + }, + TaskSource::PyprojectScripts => match pm_decision { + Some(PmDecision::Python(Ok(decision))) => { + Some(format!("{pm} run {name}", pm = decision.pm.label())) + } + _ => None, + }, + } +} + fn source_dir_for_task(task: &Task, ctx: &ProjectContext) -> Option { use crate::tool; @@ -340,7 +676,7 @@ fn print_human( mod tests { use std::path::PathBuf; - use super::{PmDecision, build_report, pm_decision_for_selected, why}; + use super::{PmDecision, build_report, build_report_v3, pm_decision_for_selected, why}; use crate::resolver::{DiagnosticFlags, ResolutionOverrides}; use crate::types::{PackageManager, ProjectContext, Task, TaskSource}; @@ -459,6 +795,153 @@ mod tests { ); } + #[test] + fn v3_report_describes_cargo_alias_like_the_committed_example() { + let mut alias = task("t", TaskSource::CargoAliases); + alias.alias_of = Some("test".to_string()); + let ctx = context(vec![alias]); + let candidates = vec![&ctx.tasks[0]]; + let selected = ctx.tasks.first(); + + let report = build_report_v3( + "t", + &candidates, + selected, + None, + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!(json["schema_version"], 3); + assert_eq!(json["kind"], "runner.why"); + assert_eq!(json["query"], "t"); + assert_eq!(json["pm_resolution"], serde_json::Value::Null); + + let task = &json["selected"]["task"]; + assert_eq!(task["name"], "t"); + assert_eq!(task["fqn"], "root:cargo-alias#t"); + assert_eq!(task["provider"], "cargo"); + assert_eq!(task["kind"], "cargo-alias"); + assert_eq!(task["source_pointer"], "alias.t"); + assert_eq!(task["definition"], "test"); + assert_eq!(task["resolved"], "cargo test"); + assert_eq!(task["dependencies"], serde_json::json!([])); + + let matched = &json["selected"]["match"]; + assert_eq!(matched["selector"], "t"); + assert_eq!(matched["matched_by"], "name"); + assert_eq!(matched["is_alias"], true); + + assert_eq!(json["candidates"].as_array().map(Vec::len), Some(1)); + assert_eq!(json["decision"]["strategy"], "single-candidate"); + assert_eq!( + json["decision"]["reason"], + "exact task name matched one candidate" + ); + } + + #[test] + fn v3_report_uses_exec_fallback_decision_when_nothing_matches() { + let ctx = context(vec![]); + let report = build_report_v3( + "nope", + &[], + None, + None, + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!(json["selected"], serde_json::Value::Null); + assert_eq!(json["candidates"], serde_json::json!([])); + assert_eq!(json["decision"]["strategy"], "exec-fallback"); + } + + #[test] + fn v3_report_ranks_multiple_candidates() { + let ctx = context(vec![ + task("build", TaskSource::PackageJson), + task("build", TaskSource::Justfile), + ]); + let candidates: Vec<&Task> = ctx.tasks.iter().collect(); + let report = build_report_v3( + "build", + &candidates, + ctx.tasks.first(), + None, + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!(json["decision"]["strategy"], "ranked"); + assert_eq!(json["candidates"].as_array().map(Vec::len), Some(2)); + // package.json resolved depends on PM resolution, which only the + // selected task gets — and no PM decision was passed here. + assert_eq!( + json["candidates"][0]["task"]["resolved"], + serde_json::Value::Null + ); + assert_eq!(json["candidates"][1]["task"]["resolved"], "just build"); + } + + #[test] + fn v3_report_resolves_selected_pyproject_script_through_python_pm() { + let mut ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]); + ctx.package_managers.push(PackageManager::Uv); + let selected = ctx.tasks.first(); + let pm_decision = pm_decision_for_selected(&ctx, &ResolutionOverrides::default(), selected) + .expect("pyproject task should resolve PM diagnostics"); + let candidates = vec![&ctx.tasks[0]]; + + let report = build_report_v3( + "greenpy", + &candidates, + selected, + Some(&pm_decision), + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!(json["selected"]["task"]["provider"], "python"); + assert_eq!(json["selected"]["task"]["resolved"], "uv run greenpy"); + assert_eq!( + json["selected"]["task"]["source_pointer"], + "project.scripts.greenpy" + ); + } + + #[test] + fn v3_report_collects_sibling_aliases() { + let mut shortcut = task("f", TaskSource::Justfile); + shortcut.alias_of = Some("fmt".to_string()); + let ctx = context(vec![task("fmt", TaskSource::Justfile), shortcut]); + let candidates = vec![&ctx.tasks[0]]; + + let report = build_report_v3( + "fmt", + &candidates, + ctx.tasks.first(), + None, + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!( + json["selected"]["task"]["aliases"], + serde_json::json!(["f"]) + ); + } + #[test] fn why_pyproject_script_reports_python_pm_override() { let ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]); diff --git a/src/complete/mod.rs b/src/complete/mod.rs index 0b74fbeb..1d93b849 100644 --- a/src/complete/mod.rs +++ b/src/complete/mod.rs @@ -561,21 +561,11 @@ mod tests { assert_eq!(strip_tag_prefix("package.json", "package.json"), ""); } - /// `_files` internals (and user zstyles keyed on the `globbed-files` - /// tag) evaluate specs containing unquoted `*`. Under zsh's default - /// `NOMATCH` behaviour those raise `no matches found: *:globbed-files` - /// into the user's prompt; under `NO_NOMATCH`, the unmatched pattern - /// (e.g. `*(/)` from `_files -/`) instead survives as a literal and - /// gets inserted into the command line. The completion function must - /// scope `NULL_GLOB` via `emulate -L zsh` so unmatched globs silently - /// drop out — no error, and no literal to leak. - /// - /// `EXTENDED_GLOB` is required on top of `NULL_GLOB` because zsh's - /// own `_files` builds qualifier patterns like `*(#q-/)` and uses - /// `(#b)` backreferences internally. Without extended glob, running - /// `_files -/` raises `bad pattern: *(#q-/):globbed-files` — - /// `emulate -L zsh` strips `EXTENDED_GLOB` from the caller's shell - /// unless we explicitly opt back in. + /// `_files` evaluates specs with unquoted `*`. `NULL_GLOB` makes + /// unmatched globs drop out silently instead of erroring or leaking + /// the literal pattern into the command line. `EXTENDED_GLOB` is + /// also needed because `_files` builds `*(#q-/)` qualifiers, and + /// `emulate -L zsh` would otherwise strip it from the caller's shell. #[test] fn registration_script_uses_null_glob_and_extended_glob() { let mut buf = Vec::new(); @@ -591,13 +581,10 @@ mod tests { ); } - /// `setopt noglob` inside the function would disable globbing in - /// `_path_files`'s internal `tmp1=( $~tmp1 )` expansion as well, - /// leaving the directory-qualifier pattern (`*(-/)`) unexpanded - /// and thus leaked as a candidate — defeating the `NULL_GLOB` fix. - /// The `noglob` *precommand modifier* only suppresses glob expansion - /// on the arguments of the `_files` call, not its internals, so the - /// two must never be confused in this script. + /// Must use the `noglob` precommand modifier, not `setopt noglob`: + /// the latter also disables `_files`'s internal `$~tmp1` expansion, + /// leaking the directory qualifier and defeating the `NULL_GLOB` fix. + /// The precommand form only suppresses globbing on `_files`'s args. #[test] fn registration_script_uses_noglob_precommand_not_setopt() { let mut buf = Vec::new(); diff --git a/src/detect.rs b/src/detect.rs index 3579ca9e..6c71d2cc 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -295,20 +295,14 @@ fn detect_monorepo(dir: &Path, ctx: &mut ProjectContext) { /// Extract tasks only from tools that were actually detected, avoiding /// unnecessary filesystem reads. /// -/// Each enabled extractor runs in its own scoped thread. The slow path -/// is always a subprocess wait — `just --summary --justfile `, -/// `mise tasks ls`, `task --list`, `cargo metadata`, `turbo run` schema -/// reads, etc. — and those waits dominate cold-run wall-clock for any -/// project that detects more than two task sources. Serial extraction -/// costs O(N) subprocesses worth of latency; scoped parallelism brings -/// it down to ~`max(extractor_latency)` plus thread-spawn overhead. +/// Each enabled extractor runs in its own scoped thread: the slow path +/// is a subprocess wait (`just --summary`, `mise tasks ls`, `cargo +/// metadata`, …) that dominates cold-run wall-clock, so parallelism cuts +/// total latency to roughly the slowest single extractor. /// -/// Pushes are applied in the original declaration order so the task -/// list keeps the source ordering the resolver and snapshot tests -/// rely on. `JoinHandle::join` panics propagate the same way a panic -/// in the previous serial code would have, which is the right -/// behavior; silently swallowing a poisoned extractor would mask a -/// real bug. +/// Results are applied in declaration order so the task list keeps the +/// source ordering the resolver and snapshot tests rely on. Extractor +/// panics propagate through `join` rather than being swallowed. fn extract_tasks(dir: &Path, ctx: &mut ProjectContext) { use std::thread; @@ -386,7 +380,7 @@ fn extract_tasks(dir: &Path, ctx: &mut ProjectContext) { ); } if let Some(h) = deno_h { - push_named_tasks( + push_described_tasks( ctx, TaskSource::DenoJson, h.join().expect("extractor thread panicked"), @@ -470,7 +464,11 @@ fn push_cargo_aliases( match result { Ok(entries) => { for entry in entries { - let alias_of = Some(entry.display_command()); + // A self-expanding entry (`test → test`) is the canonical + // subcommand, not an alias; only a differing expansion + // makes it a rename worth recording as `alias_of`. + let display = entry.display_command(); + let alias_of = (display != entry.name).then_some(display); ctx.tasks.push(Task { name: entry.name, source: TaskSource::CargoAliases, @@ -904,10 +902,9 @@ mod tests { #[test] fn detect_lists_scripts_without_lockfile_or_pm_field() { - // Headline regression: a `package.json` with scripts but no - // lockfile and no `packageManager`/`devEngines` field (a typical - // pnpm-workspace member dir) used to detect zero node PMs and so - // skip script extraction entirely → "No project detected". + // A `package.json` with scripts but no lockfile and no + // `packageManager`/`devEngines` field (a typical pnpm-workspace + // member) must still list its scripts despite detecting no PM. let dir = TempDir::new("detect-scripts-no-pm-signal"); fs::write( dir.path().join("package.json"), diff --git a/src/lib.rs b/src/lib.rs index 729e45f0..ced950b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -583,6 +583,28 @@ fn schema_version_for_json(json: bool, requested: Option) -> Result { } } +/// `why`-specific version resolution: `why` is at +/// [`schema::WHY_CURRENT_VERSION`] while list remains at +/// [`schema::CURRENT_VERSION`], so it validates against its own range +/// and defaults to its own latest. +fn why_schema_version_for_json(json: bool, requested: Option) -> Result { + if json { + schema::validate_why_schema_version(requested.unwrap_or(schema::WHY_CURRENT_VERSION)) + } else { + Ok(schema::WHY_CURRENT_VERSION) + } +} + +/// `doctor`-specific version resolution; see +/// [`schema::DOCTOR_CURRENT_VERSION`]. +fn doctor_schema_version_for_json(json: bool, requested: Option) -> Result { + if json { + schema::validate_doctor_schema_version(requested.unwrap_or(schema::DOCTOR_CURRENT_VERSION)) + } else { + Ok(schema::DOCTOR_CURRENT_VERSION) + } +} + /// Build [`resolver::ResolutionOverrides`] from a parsed CLI + loaded config. /// Lifted out of [`dispatch`] so the latter stays under clippy's /// `too_many_lines` budget; the chain-failure inputs come from whichever @@ -767,12 +789,12 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { #[cfg(feature = "schema")] 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)?; + let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?; cmd::doctor(&ctx, &overrides, json, schema_version)?; Ok(0) } Some(cli::Command::Why { task, json }) => { - let schema_version = schema_version_for_json(json, cli.global.schema_version)?; + let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?; cmd::why(&ctx, &overrides, &task, json, schema_version)?; Ok(0) } diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index 665d6992..420af7d4 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -49,7 +49,7 @@ pub(crate) use probe::probe_in as probe_path_for_doctor; pub(crate) use types::PmOverride; pub(crate) use types::{ DiagnosticFlags, FallbackPolicy, MismatchPolicy, OverrideOrigin, ResolutionOverrides, - ResolvedPm, Resolver, + ResolutionStep, ResolvedPm, Resolver, }; /// Join an iterator of `&'static str` labels with `", "`. Used by the @@ -348,12 +348,9 @@ mod tests { #[test] fn cross_ecosystem_pm_override_for_node_scripts_is_a_hard_error() { - // Pre-B5 behavior silently dropped the override and fell - // through to the lockfile signal. That hid user intent — an - // explicit `--pm cargo` on a Node project is almost always a - // misconfiguration the user wants surfaced, not silently - // disregarded. Now `InvalidOverride` is returned so `main` - // exits 2 with a clear message. + // An explicit `--pm cargo` on a Node project is a + // misconfiguration to surface, not silently drop: it must return + // `InvalidOverride` so `main` exits 2 with a clear message. let ctx = context(vec![PackageManager::Pnpm]); let overrides = with_pm_override(PackageManager::Cargo, OverrideOrigin::CliFlag); let err = Resolver::new(&ctx, &overrides) diff --git a/src/resolver/overrides.rs b/src/resolver/overrides.rs index 82dbcb55..ec91b457 100644 --- a/src/resolver/overrides.rs +++ b/src/resolver/overrides.rs @@ -271,11 +271,9 @@ 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. +/// [`char::escape_debug`], then truncated to [`MAX_RAW_DISPLAY`] chars. +/// Env values can be arbitrary captured command output (e.g. a +/// PowerShell REPL banner from an unquoted assignment), hence both. fn sanitize_raw_label(raw: &str) -> String { let escaped: String = raw.chars().flat_map(char::escape_debug).collect(); let mut chars = escaped.chars(); @@ -470,11 +468,9 @@ struct SourceNames { } 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. + /// Prefix `err` with the source that supplied `raw`. Line breaks + /// signal captured command output rather than a typed name, 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 }; diff --git a/src/resolver/probe.rs b/src/resolver/probe.rs index 3eb1929c..cbd6e582 100644 --- a/src/resolver/probe.rs +++ b/src/resolver/probe.rs @@ -4,25 +4,16 @@ //! package manager, this module walks `$PATH` (and `PATHEXT` on Windows) //! to discover what is actually installed. The Node ecosystem returns the //! first match in canonical order — `bun > pnpm > yarn > npm` — matching -//! the priority used elsewhere in detection. Phase 8 (this same step) is -//! what replaces the silent `npm` fallback baked into the resolver since -//! day one. +//! the priority used elsewhere in detection. This replaces the silent +//! `npm` fallback the resolver used to default to. //! //! ## Caching //! -//! [`probe`] memoizes results in a static `[OnceLock>; -//! PackageManager::COUNT]` array indexed by [`PackageManager::index`]. -//! Per-name `OnceLock::get_or_init` gives exactly-once probing — even -//! across concurrent callers — without ever holding a lock during the -//! PATH walk: `OnceLock` synchronises on the slot itself, so racing -//! callers all return the same value computed by the first one to -//! win the init race. No `Mutex` held across syscalls; the universe -//! of probed names is closed at compile time, so an array beats a -//! `HashMap` on lookup cost and avoids any allocation after start-up. -//! -//! The pure-function variant [`probe_in`] stays cache-free so tests -//! exercise the search logic against a controlled directory without -//! racing or polluting the shared cache. +//! [`probe`] memoizes per-PM in a static `OnceLock` array, giving +//! exactly-once probing across concurrent callers without holding a lock +//! during the PATH walk. The pure-function variant [`probe_in`] stays +//! cache-free so tests can exercise the search against a controlled +//! directory without racing or polluting the shared cache. use std::path::PathBuf; use std::sync::OnceLock; @@ -186,14 +177,10 @@ mod tests { #[test] fn probe_returns_consistent_value_across_calls() { - // `OnceLock` guarantees the initialiser runs at most once, - // so subsequent calls to `probe(pm)` return clones of the - // same cached `Option`. The slot is process-wide - // and never cleared (that's the point — no Mutex held across - // PATH walks); the test asserts the caller-visible property - // (idempotence) rather than poking at the cache internals, - // which would tie this test to other tests that may have - // already populated the same slot in this process. + // Asserts the caller-visible property — repeated `probe(pm)` + // calls return the same cached value — rather than poking at + // cache internals, which other tests in this process may have + // already populated. use super::probe; use crate::types::PackageManager; diff --git a/src/resolver/resolve.rs b/src/resolver/resolve.rs index d12dcc47..1a03e775 100644 --- a/src/resolver/resolve.rs +++ b/src/resolver/resolve.rs @@ -108,11 +108,9 @@ impl<'ctx> Resolver<'ctx> { // Filter `primary_pm` through `can_dispatch_node_scripts` so a // non-script PM (Cargo/Poetry/Bundler/…) doesn't satisfy the - // Node lockfile step. Without the filter, a mixed-language repo - // with `package.json` scripts but only `Cargo.lock` as the - // top-priority signal would return Cargo here and later bail - // with the opaque "cargo cannot run scripts" branch instead of - // continuing to the PATH probe / fallback. + // Node lockfile step. Otherwise a mixed-language repo whose + // top-priority signal is `Cargo.lock` would pick Cargo and bail + // later, instead of continuing to the PATH probe / fallback. if let Some(pm) = self.ctx.primary_node_pm().or_else(|| { self.ctx .primary_pm() @@ -127,13 +125,10 @@ impl<'ctx> Resolver<'ctx> { match self.overrides.fallback { FallbackPolicy::Probe => { - // Don't probe Node PMs in projects with no Node-ecosystem - // evidence. Earlier steps already covered overrides, - // manifest declarations, and lockfiles; the absence of a - // `package.json` anywhere upward means this isn't a Node - // project, and picking up `bun`/`pnpm`/`yarn`/`npm` from - // `$PATH` would dispatch through the wrong ecosystem - // (see issue #23: `runner run list` in a Go repo). + // Don't probe Node PMs without Node-ecosystem evidence. + // No `package.json` upward means this isn't a Node + // project, so picking `bun`/`pnpm`/`yarn`/`npm` off + // `$PATH` would dispatch through the wrong ecosystem. if find_manifest_upwards(&self.ctx.root).is_none() { return Err(no_pm_found_soft()); } diff --git a/src/schema/doctor_v3.rs b/src/schema/doctor_v3.rs new file mode 100644 index 00000000..caa964ed --- /dev/null +++ b/src/schema/doctor_v3.rs @@ -0,0 +1,1261 @@ +//! `doctor --json` schema **v3** — the structured diagnostic report. +//! +//! Implements the contract drafted in `schemas/doctor.v3-draft.schema.json` +//! (now retired): instead of v2's flat detection dump, the report is an +//! inventory — `invocation`/`environment`/`runner` provenance, per-ecosystem +//! decisions with confidence, task `sources` as first-class objects, tasks +//! with stable `fqn`s, PATH-probe `tools`, duplicate-name `conflicts`, and +//! flattened `diagnostics` — plus a self-describing `resolution` policy +//! block. +//! +//! Deliberate deltas from the draft, found while reviewing it against the +//! codebase: +//! +//! - `tasks[].resolved` and `tasks[].source` are nullable: a +//! `package.json` script's command depends on PM resolution, which can +//! fail, and a source anchor file can be undiscoverable. The draft +//! required both non-null; lying was the alternative. +//! - `sources[].kind` uses the v3 source labels (`cargo-alias`, `just`, +//! …) for cross-surface consistency with `why` v3, not the draft's +//! filename-flavored examples (`cargo-config`, `justfile`). +//! - `overrides.pm`/`overrides.runner` are bare labels (per draft); the +//! provenance (`cli`/`env`/`config:…`) remains available on the v2 +//! surface. +//! - `project.workspace` is always `null` and `project.root_source` is +//! the root itself until workspace/root-anchor detection is modeled. +//! - Speculative draft shapes nothing can emit yet are deferred rather +//! than declared: the rich `dependency` object (`tasks[].dependencies` +//! stays an always-empty array), `workspace`/`package_identity` +//! objects (fields stay null), the `tool_probe_error` variant (the +//! probe cannot error), the `binary`/`package-binary` tool kinds, and +//! the `debug`/`error` severities. Each gets declared when an +//! emitter exists — contracts should describe output, not ambition. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use super::labels::source_label_for; +use crate::cmd::run::{resolve_python_pm, select_task_entry, source_depth, source_priority}; +use crate::resolver::{ + FallbackPolicy, MismatchPolicy, ResolutionOverrides, ResolutionStep, Resolver, +}; +use crate::tool::node::detect_pm_from_manifest; +use crate::types::{DetectionWarning, Ecosystem, PackageManager, ProjectContext, Task, TaskSource}; + +/// `runner doctor --json --schema-version 3` payload. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +pub(crate) struct DoctorReportV3<'a> { + #[serde(rename = "$schema")] + #[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, + #[cfg_attr( + feature = "schema", + schemars(description = "Payload discriminator; always \"runner.doctor\".") + )] + kind: &'static str, + invocation: InvocationV3, + environment: EnvironmentV3, + runner: RunnerInfoV3, + project: ProjectInfoV3, + overrides: OverridesV3, + ecosystems: Vec, + sources: Vec, + tasks: Vec>, + tools: Vec, + conflicts: Vec, + diagnostics: Vec, + resolution: ResolutionPolicyV3, +} + +/// How this report came to be: the exact process invocation. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct InvocationV3 { + argv: Vec, + cwd: String, + #[cfg_attr( + feature = "schema", + schemars(description = "UTC RFC 3339 timestamp of report generation.") + )] + started_at: String, +} + +/// Host facts that influence probing and dispatch. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct EnvironmentV3 { + arch: &'static str, + os: &'static str, + path_entries: Vec, + shell: Option, +} + +/// The reporting binary's own identity and contract versions. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct RunnerInfoV3 { + binary: String, + name: String, + version: &'static str, + schema_versions: SchemaVersionsV3, +} + +/// Latest schema version each `--json` surface speaks. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct SchemaVersionsV3 { + doctor: u32, + list: u32, + why: u32, +} + +/// Project anchoring facts. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct ProjectInfoV3 { + monorepo: bool, + root: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "What anchored root detection. Currently always the root itself (cwd or --dir); a dedicated anchor model is future work." + ) + )] + root_source: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "Workspace identity. Always null today: workspace kind/root detection is not yet modeled (the monorepo flag is the coarse signal)." + ) + )] + workspace: Option, +} + +/// Effective override stack, labels only. Provenance (cli/env/config) +/// stays on the v2 surface. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct OverridesV3 { + explain: bool, + fallback: &'static str, + no_warnings: bool, + on_mismatch: &'static str, + pm: Option<&'static str>, + pm_by_ecosystem: BTreeMap>, + prefer_runners: Vec<&'static str>, + runner: Option<&'static str>, +} + +/// One detected ecosystem and the PM decision made for it. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct EcosystemV3 { + decision: EcosystemDecisionV3, + name: &'static str, + root: String, + selected_package_manager: Option<&'static str>, + #[cfg_attr( + feature = "schema", + schemars( + description = "Detection evidence. Node carries the full signal set (lockfile/manifest/PATH probe/shim classification, keyed by tool with the shim manager as data); other ecosystems list their detected package managers." + ) + )] + signals: serde_json::Value, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct EcosystemDecisionV3 { + confidence: ConfidenceV3, + reason: String, + selected: Option<&'static str>, +} + +/// How sure the resolver is about an ecosystem's PM selection. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +enum ConfidenceV3 { + /// Explicit signal: override, manifest declaration, or lockfile. + High, + /// Inferred: PATH probe found a usable binary. + Medium, + /// Legacy `--fallback npm` default with no signal at all. + Low, + /// Resolution failed. + None, +} + +/// One task-source config file as a first-class object. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct SourceV3 { + exists: bool, + #[cfg_attr( + feature = "schema", + schemars(description = "Stable source identity: `src::`.") + )] + id: String, + #[cfg_attr( + feature = "schema", + schemars(description = "v3 source label (same convention as `why` v3).") + )] + kind: &'static str, + #[cfg_attr( + feature = "schema", + schemars(description = "Package identity for manifest-backed sources. Null today.") + )] + package: Option, + path: String, + relpath: String, + #[cfg_attr( + feature = "schema", + schemars(description = "Project-root-relative scope; `root` until member scoping lands.") + )] + scope: &'static str, + #[cfg_attr( + feature = "schema", + schemars( + description = "Key of the container holding tasks inside the file (`scripts`, `tasks`, `alias`, …); null for flat-format files." + ) + )] + task_pointer: Option<&'static str>, +} + +/// One task in the doctor inventory. Same identity scheme as `why` v3 +/// (`fqn`, `source_pointer`, `aliases`, `definition`, `resolved`). +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct DoctorTaskV3<'a> { + aliases: Vec<&'a str>, + cwd: String, + definition: Option<&'a str>, + #[cfg_attr( + feature = "schema", + schemars( + description = "Task dependencies. Always empty today: no extractor records dependency edges yet; the edge shape lands with the first extractor." + ) + )] + dependencies: Vec, + description: Option<&'a str>, + fqn: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "True when this task is an alias for another target; `definition` holds the target it expands to (e.g. cargo `b` → `build`)." + ) + )] + is_alias: bool, + name: &'a str, + #[cfg_attr( + feature = "schema", + schemars( + description = "Effective command preview. Null when it depends on a PM resolution that failed." + ) + )] + resolved: Option, + #[cfg_attr( + feature = "schema", + schemars( + description = "True when runner can run this task without its source's primary tool. Only deno tasks runner can execute via the embedded task shell (leaf command, no `dependencies`, no `deno` invocation) qualify today; all other sources are false." + ) + )] + self_executable: bool, + source: Option, + source_pointer: Option, +} + +/// What kind of thing a probed tool is. The draft's `binary` / +/// `package-binary` kinds join when something probes them. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +enum DependencyKindV3 { + Runtime, + PackageManager, + TaskRunner, +} + +impl DependencyKindV3 { + const fn label(self) -> &'static str { + match self { + Self::Runtime => "runtime", + Self::PackageManager => "package-manager", + Self::TaskRunner => "task-runner", + } + } +} + +/// One PATH-probed tool the project relies on. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct ToolV3 { + #[cfg_attr( + feature = "schema", + schemars(description = "Stable tool identity: `tool::`.") + )] + id: String, + kind: DependencyKindV3, + name: &'static str, + probe: ToolProbeV3, + required: bool, +} + +/// PATH-probe outcome, tagged by `status`. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "lowercase")] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +enum ToolProbeV3 { + Found { + path: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "Resolved version: taken from detection when known, otherwise read by running ` --version`. Null when the binary reports no parseable version." + ) + )] + version: Option, + }, + Missing, +} + +/// A task name claimed by more than one source: who wins, who is shadowed. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct ConflictV3 { + kind: &'static str, + reason: String, + #[cfg_attr(feature = "schema", schemars(description = "FQN of the winning task."))] + selected: String, + selector: String, + severity: SeverityV3, + shadowed: Vec, +} + +/// Severity of a conflict or diagnostic. The draft's `debug`/`error` +/// levels join when something emits them. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +enum SeverityV3 { + Info, + Warning, +} + +/// One detection/resolution diagnostic, flattened from the warning +/// streams. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct DiagnosticV3 { + #[cfg_attr( + feature = "schema", + schemars(description = "Stable warning category (the warning's source subsystem).") + )] + code: &'static str, + message: String, + severity: SeverityV3, + source: Option<&'static str>, + task: Option, +} + +/// Self-description of the task-selection policy, so consumers don't +/// hardcode runner's precedence rules. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] +struct ResolutionPolicyV3 { + fqn_policy: &'static str, + precedence: Vec<&'static str>, + short_name_policy: &'static str, +} + +impl<'a> DoctorReportV3<'a> { + /// Build the v3 report. `resolve_shims` is forwarded to the Volta + /// shim classifier exactly like the v2 builder. + pub(crate) fn build( + ctx: &'a ProjectContext, + overrides: &ResolutionOverrides, + resolve_shims: bool, + ) -> Self { + let node_pm = Resolver::new(ctx, overrides).resolve_node_pm(); + let schema_version = super::DOCTOR_CURRENT_VERSION; + + let diagnostics = ctx + .warnings + .iter() + .chain(node_pm.as_ref().map_or(&[][..], |d| &d.warnings)) + .map(diagnostic_v3) + .collect(); + + Self { + schema: super::schema_url("doctor", schema_version), + schema_version, + kind: "runner.doctor", + invocation: invocation_v3(), + environment: environment_v3(), + runner: runner_info_v3(), + project: ProjectInfoV3 { + monorepo: ctx.is_monorepo, + root: ctx.root.display().to_string(), + root_source: ctx.root.display().to_string(), + workspace: None, + }, + overrides: overrides_v3(overrides), + ecosystems: ecosystems_v3(ctx, overrides, &node_pm, resolve_shims), + sources: sources_v3(ctx, schema_version), + tasks: tasks_v3(ctx, &node_pm, overrides, schema_version), + tools: tools_v3(ctx, &node_pm), + conflicts: conflicts_v3(ctx, overrides, schema_version), + diagnostics, + resolution: ResolutionPolicyV3 { + fqn_policy: "exact-only", + precedence: vec![ + "source-priority", + "source-depth", + "display-order", + "alias-last", + ], + short_name_policy: "deterministic-precedence", + }, + } + } +} + +fn invocation_v3() -> InvocationV3 { + InvocationV3 { + argv: std::env::args().collect(), + cwd: std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(), + started_at: rfc3339_utc_now(), + } +} + +fn environment_v3() -> EnvironmentV3 { + EnvironmentV3 { + arch: std::env::consts::ARCH, + os: std::env::consts::OS, + path_entries: std::env::var_os("PATH") + .map(|path| { + std::env::split_paths(&path) + .map(|entry| entry.display().to_string()) + .collect() + }) + .unwrap_or_default(), + shell: std::env::var("SHELL").ok(), + } +} + +fn runner_info_v3() -> RunnerInfoV3 { + let binary = std::env::current_exe() + .map_or_else(|_| "runner".to_string(), |exe| exe.display().to_string()); + let name = std::env::args_os() + .next() + .and_then(|arg0| crate::bin_name_from_arg0(&arg0)) + .unwrap_or_else(|| "runner".to_string()); + RunnerInfoV3 { + binary, + name, + version: env!("CARGO_PKG_VERSION"), + schema_versions: SchemaVersionsV3 { + doctor: super::DOCTOR_CURRENT_VERSION, + list: super::CURRENT_VERSION, + why: super::WHY_CURRENT_VERSION, + }, + } +} + +fn overrides_v3(overrides: &ResolutionOverrides) -> OverridesV3 { + OverridesV3 { + explain: overrides.explain, + fallback: match overrides.fallback { + FallbackPolicy::Probe => "probe", + FallbackPolicy::Npm => "npm", + FallbackPolicy::Error => "error", + }, + no_warnings: overrides.no_warnings, + on_mismatch: match overrides.on_mismatch { + MismatchPolicy::Warn => "warn", + MismatchPolicy::Error => "error", + MismatchPolicy::Ignore => "ignore", + }, + pm: overrides.pm.as_ref().map(|o| o.pm.label()), + pm_by_ecosystem: overrides + .pm_by_ecosystem + .iter() + .map(|(eco, o)| (eco.label().to_string(), Some(o.pm.label()))) + .collect(), + prefer_runners: overrides.prefer_runners.iter().map(|r| r.label()).collect(), + runner: overrides.runner.as_ref().map(|o| o.runner.label()), + } +} + +fn ecosystems_v3( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, + node_pm: &Result, + resolve_shims: bool, +) -> Vec { + let mut seen = Vec::new(); + for pm in &ctx.package_managers { + let eco = pm.ecosystem(); + if !seen.contains(&eco) { + seen.push(eco); + } + } + + // Seeding from detected `package_managers` alone misses every Node + // resolution that doesn't leave a lockfile-detected PM behind + // (manifest `packageManager` without a lockfile, PATH-probe, npm + // fallback, override). In those cases `tasks_v3` still resolves + // `package.json` scripts via `npm run`, so dropping Node here would + // emit an internally inconsistent document. Same predicate gates the + // node runtime entry in `tools_v3`. + if has_node_context(ctx, node_pm) && !seen.contains(&Ecosystem::Node) { + seen.push(Ecosystem::Node); + } + + seen.into_iter() + .map(|eco| match eco { + Ecosystem::Node => node_ecosystem_v3(ctx, node_pm, resolve_shims), + Ecosystem::Python => python_ecosystem_v3(ctx, overrides), + other => single_pm_ecosystem_v3(ctx, other), + }) + .collect() +} + +/// Whether the project carries Node context, considering resolver and +/// task signals — not just lockfile-detected `package_managers`. A Node +/// PM decision (`resolve_node_pm` Ok), a detected Node-ecosystem PM, or +/// any `package.json`-sourced task each count. Gates Node inclusion in +/// both [`ecosystems_v3`] and [`tools_v3`] so the two surfaces never +/// disagree with what `tasks_v3` resolves. +fn has_node_context( + ctx: &ProjectContext, + node_pm: &Result, +) -> bool { + node_pm.is_ok() + || ctx + .package_managers + .iter() + .any(|pm| pm.ecosystem() == Ecosystem::Node) + || ctx + .tasks + .iter() + .any(|t| matches!(t.source, TaskSource::PackageJson)) +} + +fn node_ecosystem_v3( + ctx: &ProjectContext, + node_pm: &Result, + resolve_shims: bool, +) -> EcosystemV3 { + let (decision, selected) = match node_pm { + Ok(decision) => ( + EcosystemDecisionV3 { + confidence: confidence_for_step(&decision.via), + reason: decision.describe(), + selected: Some(decision.pm.label()), + }, + Some(decision.pm.label()), + ), + Err(err) => ( + EcosystemDecisionV3 { + confidence: ConfidenceV3::None, + reason: format!("{err}"), + selected: None, + }, + None, + ), + }; + + let manifest_decl = detect_pm_from_manifest(&ctx.root); + let probes = super::project::probe_signals(&ctx.root, resolve_shims); + // Shims are keyed by tool and carry the shim *manager* as data, not + // as the field name — Volta is merely the first manager the prober + // classifies; asdf/mise/proto entries slot in without a contract + // change. (v2's `volta_shims` spelling is frozen; only v3 gets the + // generic shape.) + let shims = probes + .volta_shims + .iter() + .map(|(name, shim)| { + ( + (*name).to_string(), + serde_json::json!({ "manager": "volta", "resolved": shim.resolved }), + ) + }) + .collect::>(); + let signals = serde_json::json!({ + "lockfile_pm": ctx.primary_node_pm().map(PackageManager::label), + "manifest_pm": manifest_decl.as_ref().map(|d| d.pm.label()), + "path_probe": probes.path_probe, + "shims": shims, + }); + + EcosystemV3 { + decision, + name: "node", + root: ctx.root.display().to_string(), + selected_package_manager: selected, + signals, + } +} + +fn python_ecosystem_v3(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> EcosystemV3 { + let resolved = resolve_python_pm(ctx, overrides); + let (decision, selected) = resolved.map_or_else( + || { + ( + EcosystemDecisionV3 { + confidence: ConfidenceV3::None, + reason: "no Python package manager detected".to_string(), + selected: None, + }, + None, + ) + }, + |decision| { + let label = decision.pm.label(); + ( + EcosystemDecisionV3 { + confidence: ConfidenceV3::High, + reason: decision.describe(), + selected: Some(label), + }, + Some(label), + ) + }, + ); + + EcosystemV3 { + decision, + name: "python", + root: ctx.root.display().to_string(), + selected_package_manager: selected, + signals: detected_pm_signals(ctx, Ecosystem::Python), + } +} + +/// Single-PM ecosystems (rust/go/deno/ruby/php): the detected manager +/// *is* the decision — there is no competing-PM resolution chain. +fn single_pm_ecosystem_v3(ctx: &ProjectContext, eco: Ecosystem) -> EcosystemV3 { + let selected = ctx + .package_managers + .iter() + .find(|pm| pm.ecosystem() == eco) + .map(|pm| pm.label()); + + EcosystemV3 { + decision: EcosystemDecisionV3 { + confidence: ConfidenceV3::High, + reason: format!( + "detected via {} project signal", + selected.unwrap_or("manifest") + ), + selected, + }, + name: eco.label(), + root: ctx.root.display().to_string(), + selected_package_manager: selected, + signals: detected_pm_signals(ctx, eco), + } +} + +fn detected_pm_signals(ctx: &ProjectContext, eco: Ecosystem) -> serde_json::Value { + serde_json::json!({ + "package_managers": ctx + .package_managers + .iter() + .filter(|pm| pm.ecosystem() == eco) + .map(|pm| pm.label()) + .collect::>(), + }) +} + +const fn confidence_for_step(step: &ResolutionStep) -> ConfidenceV3 { + match step { + ResolutionStep::Override(_) + | ResolutionStep::ManifestPackageManager + | ResolutionStep::ManifestDevEngines { .. } + | ResolutionStep::Lockfile => ConfidenceV3::High, + ResolutionStep::PathProbe { .. } => ConfidenceV3::Medium, + ResolutionStep::LegacyNpmFallback => ConfidenceV3::Low, + } +} + +fn sources_v3(ctx: &ProjectContext, schema_version: u32) -> Vec { + let mut seen: Vec = Vec::new(); + for task in &ctx.tasks { + if !seen.contains(&task.source) { + seen.push(task.source); + } + } + + seen.into_iter() + .map(|source| { + let kind = source_label_for(source, schema_version); + let anchor = anchor_file(source, &ctx.root); + let path = anchor + .as_ref() + .map_or_else(String::new, |p| p.display().to_string()); + let relpath = anchor.as_ref().map_or_else(String::new, |p| { + p.strip_prefix(&ctx.root).unwrap_or(p).display().to_string() + }); + SourceV3 { + exists: anchor.as_ref().is_some_and(|p| p.is_file()), + id: format!("src:root:{kind}"), + kind, + package: None, + path, + relpath, + scope: "root", + task_pointer: task_container_key(source), + } + }) + .collect() +} + +fn tasks_v3<'a>( + ctx: &'a ProjectContext, + node_pm: &Result, + overrides: &ResolutionOverrides, + schema_version: u32, +) -> Vec> { + let node_pm_label = node_pm.as_ref().ok().map(|d| d.pm.label()); + let python_pm_label = resolve_python_pm(ctx, overrides).map(|d| d.pm.label()); + + // `anchor_file` walks the filesystem; resolve each distinct source + // once instead of once per task. + let mut anchors: std::collections::HashMap> = + std::collections::HashMap::new(); + for task in &ctx.tasks { + anchors.entry(task.source).or_insert_with(|| { + anchor_file(task.source, &ctx.root).map(|p| p.display().to_string()) + }); + } + + ctx.tasks + .iter() + .map(|task| DoctorTaskV3 { + aliases: ctx + .tasks + .iter() + .filter(|other| { + other.source == task.source && other.alias_of.as_deref() == Some(&task.name) + }) + .map(|other| other.name.as_str()) + .collect(), + cwd: ctx.root.display().to_string(), + definition: task.alias_of.as_deref().or(task.run_target.as_deref()), + dependencies: Vec::new(), + description: task.description.as_deref(), + fqn: super::labels::fqn(task.source, &task.name, schema_version), + is_alias: task.alias_of.is_some(), + name: &task.name, + resolved: resolved_command_v3(task, node_pm_label, python_pm_label), + self_executable: deno_task_self_executable(ctx, task), + source: anchors.get(&task.source).cloned().flatten(), + source_pointer: source_pointer_v3(task), + }) + .collect() +} + +/// Whether runner can run `task` without its source's primary tool. +/// +/// Only deno tasks that runner can drive through the embedded task shell +/// (leaf command, no `dependencies`, no `deno` invocation) qualify; every +/// other source has no in-process fallback and is therefore `false`. +fn deno_task_self_executable(ctx: &ProjectContext, task: &Task) -> bool { + if task.source != TaskSource::DenoJson { + return false; + } + crate::tool::deno::find_config_upwards(&ctx.root) + .and_then(|config| crate::tool::deno_exec::plan(&config, &task.name)) + .is_some_and(|plan| plan.self_executable()) +} + +/// Effective command preview. Unlike `why` v3 (which only resolves the +/// PM for the selected task), doctor resolves PMs project-wide, so +/// `package.json`/`pyproject.toml` scripts resolve here whenever the +/// ecosystem resolution succeeded. +fn resolved_command_v3( + task: &Task, + node_pm: Option<&'static str>, + python_pm: Option<&'static str>, +) -> Option { + let name = &task.name; + match task.source { + TaskSource::CargoAliases => Some(task.alias_of.as_deref().map_or_else( + || format!("cargo {name}"), + |expansion| format!("cargo {expansion}"), + )), + TaskSource::DenoJson => Some(format!("deno task {name}")), + TaskSource::TurboJson => Some(format!("turbo run {name}")), + TaskSource::Makefile => Some(format!("make {name}")), + TaskSource::Justfile => Some(format!("just {name}")), + TaskSource::Taskfile => Some(format!("task {name}")), + TaskSource::BaconToml => Some(format!("bacon {name}")), + TaskSource::MiseToml => Some(format!("mise run {name}")), + TaskSource::GoPackage => Some(format!( + "go run {target}", + target = task.run_target.as_deref().unwrap_or(name) + )), + TaskSource::PackageJson => node_pm.map(|pm| format!("{pm} run {name}")), + TaskSource::PyprojectScripts => python_pm.map(|pm| format!("{pm} run {name}")), + } +} + +/// Key path locating the task inside its source file; mirrors the +/// `why` v3 convention. +fn source_pointer_v3(task: &Task) -> Option { + let name = &task.name; + match task.source { + TaskSource::CargoAliases => Some(format!("alias.{name}")), + TaskSource::PackageJson => Some(format!("scripts.{name}")), + TaskSource::DenoJson + | TaskSource::TurboJson + | TaskSource::Taskfile + | TaskSource::MiseToml => Some(format!("tasks.{name}")), + TaskSource::BaconToml => Some(format!("jobs.{name}")), + TaskSource::PyprojectScripts => Some(format!("project.scripts.{name}")), + TaskSource::Makefile | TaskSource::Justfile => Some(name.clone()), + TaskSource::GoPackage => None, + } +} + +/// Container key holding tasks inside the source file. +const fn task_container_key(source: TaskSource) -> Option<&'static str> { + match source { + TaskSource::CargoAliases => Some("alias"), + TaskSource::PackageJson => Some("scripts"), + TaskSource::DenoJson + | TaskSource::TurboJson + | TaskSource::Taskfile + | TaskSource::MiseToml => Some("tasks"), + TaskSource::BaconToml => Some("jobs"), + TaskSource::PyprojectScripts => Some("project.scripts"), + TaskSource::Makefile | TaskSource::Justfile | TaskSource::GoPackage => None, + } +} + +/// Config file anchoring a task source. Mirrors `cmd::why`'s anchor +/// walk (file paths, not parent dirs). +fn anchor_file(source: TaskSource, root: &Path) -> Option { + use crate::tool; + + match source { + TaskSource::PackageJson => tool::node::find_manifest_upwards(root), + TaskSource::DenoJson => tool::deno::find_config_upwards(root), + TaskSource::TurboJson => tool::turbo::find_config(root), + TaskSource::Makefile => tool::files::find_first(root, tool::make::FILENAMES), + TaskSource::Justfile => tool::just::find_file(root), + TaskSource::Taskfile => tool::files::find_first(root, tool::go_task::FILENAMES), + TaskSource::CargoAliases => tool::cargo_aliases::find_anchor(root), + TaskSource::GoPackage => tool::go_pm::find_file(root), + TaskSource::BaconToml => tool::files::find_first(root, tool::bacon::FILENAMES), + TaskSource::MiseToml => tool::mise::find_file(root), + TaskSource::PyprojectScripts => tool::python::find_pyproject_upwards(root), + } +} + +fn tools_v3( + ctx: &ProjectContext, + node_pm: &Result, +) -> Vec { + let path = std::env::var_os("PATH").unwrap_or_default(); + let pathext = std::env::var_os("PATHEXT"); + let pathext_ref = pathext.as_deref(); + + let mut tools = Vec::new(); + + if has_node_context(ctx, node_pm) { + tools.push(probe_tool( + "node", + DependencyKindV3::Runtime, + ctx.current_node + .as_deref() + .map(|v| v.trim_start_matches('v').to_string()), + true, + &path, + pathext_ref, + )); + } + + // Deno is required only when at least one deno task can't be + // self-executed (it has dependencies or invokes `deno`); a project + // whose deno tasks all run through the embedded shell does not need + // the binary. Every other tool has no in-process fallback. + let deno_required = ctx + .tasks + .iter() + .filter(|task| task.source == TaskSource::DenoJson) + .any(|task| !deno_task_self_executable(ctx, task)); + + for pm in &ctx.package_managers { + let required = if *pm == PackageManager::Deno { + deno_required + } else { + true + }; + tools.push(probe_tool( + pm_binary_name(*pm), + DependencyKindV3::PackageManager, + None, + required, + &path, + pathext_ref, + )); + } + for runner in &ctx.task_runners { + tools.push(probe_tool( + runner.label(), + DependencyKindV3::TaskRunner, + None, + true, + &path, + pathext_ref, + )); + } + + tools +} + +/// Binary actually probed for a PM. Labels and binaries coincide except +/// Bundler, whose CLI is `bundle`. +const fn pm_binary_name(pm: PackageManager) -> &'static str { + match pm { + PackageManager::Bundler => "bundle", + _ => pm.label(), + } +} + +fn probe_tool( + name: &'static str, + kind: DependencyKindV3, + version: Option, + required: bool, + path: &std::ffi::OsStr, + pathext: Option<&std::ffi::OsStr>, +) -> ToolV3 { + let probe = crate::resolver::probe_path_for_doctor(name, path, pathext).map_or( + ToolProbeV3::Missing, + |hit| ToolProbeV3::Found { + // Prefer a version already known from detection (the node + // runtime); otherwise ask the binary directly. + version: version.or_else(|| probe_tool_version(&hit)), + path: hit.display().to_string(), + }, + ); + ToolV3 { + id: format!("tool:{kind}:{name}", kind = kind.label()), + kind, + name, + probe, + required, + } +} + +/// Run ` --version` and extract the version string. Returns +/// `None` when the spawn fails, the process errors, or no version-like +/// token appears. Output formats vary (`cargo 1.83.0 (..)`, `just +/// 1.36.0`, `1.1.38`, `v24.14.1`), so the first whitespace-separated +/// token that looks like a dotted version wins, with any `v` prefix +/// stripped. +fn probe_tool_version(binary: &Path) -> Option { + let output = std::process::Command::new(binary) + .arg("--version") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + stdout + .split_whitespace() + .map(|token| token.trim_start_matches('v')) + .find(|token| { + // Version-like: starts with a digit and carries a dotted + // component. Accepts `1.83.0`, `24.14.1`, and prerelease + // forms like `1.85.0-nightly`; skips names and build hashes. + token.starts_with(|c: char| c.is_ascii_digit()) && token.contains('.') + }) + .map(ToString::to_string) +} + +fn conflicts_v3( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, + schema_version: u32, +) -> Vec { + let mut by_name: BTreeMap<&str, Vec<&Task>> = BTreeMap::new(); + for task in &ctx.tasks { + by_name.entry(&task.name).or_default().push(task); + } + + by_name + .into_iter() + .filter(|(_, group)| group.len() > 1) + .map(|(name, group)| { + let selected = select_task_entry(ctx, overrides, &group); + let fqn_of = |task: &Task| super::labels::fqn(task.source, &task.name, schema_version); + ConflictV3 { + kind: "duplicate-task-name", + reason: format!( + "{count} sources define `{name}`; lowest (source_priority={priority}, \ + source_depth={depth}, display_order={order}, alias-last) key wins", + count = group.len(), + priority = source_priority(overrides, selected.source), + depth = display_depth(source_depth(ctx, selected.source)), + order = selected.source.display_order(), + ), + selected: fqn_of(selected), + selector: name.to_string(), + severity: SeverityV3::Info, + shadowed: group + .iter() + .filter(|task| !std::ptr::eq(**task, selected)) + .map(|task| fqn_of(task)) + .collect(), + } + }) + .collect() +} + +fn display_depth(depth: usize) -> String { + if depth == usize::MAX { + "unresolved".to_string() + } else { + depth.to_string() + } +} + +fn diagnostic_v3(warning: &DetectionWarning) -> DiagnosticV3 { + DiagnosticV3 { + code: warning.source(), + message: warning.detail(), + severity: SeverityV3::Warning, + source: Some(warning.source()), + task: None, + } +} + +/// RFC 3339 UTC timestamp without a date-time dependency. Civil-date +/// math per Howard Hinnant's `civil_from_days` algorithm. +fn rfc3339_utc_now() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default(); + rfc3339_utc(secs) +} + +fn rfc3339_utc(secs_since_epoch: u64) -> String { + let days = i64::try_from(secs_since_epoch / 86_400).unwrap_or(i64::MAX); + let rem = secs_since_epoch % 86_400; + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z", + hour = rem / 3600, + minute = (rem % 3600) / 60, + second = rem % 60, + ) +} + +/// Days-since-epoch → (year, month, day) in the proleptic Gregorian +/// calendar. +const fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + (if month <= 2 { year + 1 } else { year }, month, day) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{DoctorReportV3, rfc3339_utc}; + use crate::resolver::ResolutionOverrides; + use crate::types::{Ecosystem, PackageManager, ProjectContext, Task, TaskSource}; + + fn context(tasks: Vec) -> ProjectContext { + ProjectContext { + root: PathBuf::from("/tmp/test"), + package_managers: vec![PackageManager::Cargo], + task_runners: Vec::new(), + tasks, + node_version: None, + current_node: None, + is_monorepo: false, + warnings: Vec::new(), + } + } + + fn task(name: &str, source: TaskSource) -> Task { + Task { + name: name.to_string(), + source, + run_target: None, + description: None, + alias_of: None, + passthrough_to: None, + } + } + + #[test] + fn rfc3339_known_vectors() { + assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(rfc3339_utc(86_400), "1970-01-02T00:00:00Z"); + // 2000-02-29 — leap day in a century-leap year. + assert_eq!(rfc3339_utc(951_782_400), "2000-02-29T00:00:00Z"); + assert_eq!(rfc3339_utc(951_868_799), "2000-02-29T23:59:59Z"); + assert_eq!(rfc3339_utc(951_868_800), "2000-03-01T00:00:00Z"); + } + + #[test] + fn v3_report_carries_contract_constants() { + let ctx = context(vec![]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + assert_eq!(json["kind"], "runner.doctor"); + assert_eq!(json["schema_version"], 3); + assert!( + json["$schema"] + .as_str() + .is_some_and(|s| s.contains("doctor.v3")) + ); + assert_eq!(json["resolution"]["fqn_policy"], "exact-only"); + assert_eq!(json["project"]["workspace"], serde_json::Value::Null); + assert!( + json["invocation"]["started_at"] + .as_str() + .is_some_and(|t| { t.len() == 20 && t.ends_with('Z') && t.as_bytes()[10] == b'T' }) + ); + } + + #[test] + fn v3_report_lists_rust_ecosystem_with_high_confidence() { + let ctx = context(vec![]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let eco = &json["ecosystems"][0]; + assert_eq!(eco["name"], "rust"); + assert_eq!(eco["selected_package_manager"], "cargo"); + assert_eq!(eco["decision"]["confidence"], "high"); + } + + #[test] + fn v3_report_surfaces_duplicate_names_as_conflicts() { + let mut alias = task("t", TaskSource::CargoAliases); + alias.alias_of = Some("test".to_string()); + let ctx = context(vec![alias, task("t", TaskSource::Justfile)]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let conflict = &json["conflicts"][0]; + assert_eq!(conflict["kind"], "duplicate-task-name"); + assert_eq!(conflict["selector"], "t"); + // The justfile recipe wins: same tier, but recipes rank before + // aliases. + assert_eq!(conflict["selected"], "root:just#t"); + assert_eq!( + conflict["shadowed"], + serde_json::json!(["root:cargo-alias#t"]) + ); + } + + #[test] + fn v3_report_resolves_cargo_alias_tasks() { + let mut alias = task("t", TaskSource::CargoAliases); + alias.alias_of = Some("test".to_string()); + let ctx = context(vec![alias]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let task = &json["tasks"][0]; + assert_eq!(task["fqn"], "root:cargo-alias#t"); + assert_eq!(task["is_alias"], true); + assert_eq!(task["definition"], "test"); + assert_eq!(task["resolved"], "cargo test"); + assert_eq!(task["source_pointer"], "alias.t"); + assert_eq!(task["dependencies"], serde_json::json!([])); + } + + #[test] + fn v3_report_keeps_node_when_only_package_json_tasks_present() { + // package.json scripts with no lockfile-detected Node PM: the + // resolver still resolves them via `npm run`, so `ecosystems` + // and `tools` must surface Node too — otherwise the document is + // internally inconsistent (tasks reference a runtime the rest of + // the report claims absent). + let ctx = context(vec![task("build", TaskSource::PackageJson)]); + assert!( + !ctx.package_managers + .iter() + .any(|pm| pm.ecosystem() == Ecosystem::Node), + "precondition: no Node PM detected" + ); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let ecosystems = json["ecosystems"].as_array().expect("ecosystems array"); + assert!( + ecosystems.iter().any(|e| e["name"] == "node"), + "node ecosystem must be present when package.json tasks exist" + ); + let tools = json["tools"].as_array().expect("tools array"); + assert!( + tools.iter().any(|t| t["name"] == "node"), + "node runtime tool must be probed when package.json tasks exist" + ); + } + + #[test] + fn v3_report_probes_detected_pms_as_tools() { + let ctx = context(vec![]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let tool = &json["tools"][0]; + assert_eq!(tool["name"], "cargo"); + assert_eq!(tool["kind"], "package-manager"); + assert_eq!(tool["id"], "tool:package-manager:cargo"); + let status = tool["probe"]["status"].as_str().expect("probe status"); + assert!(status == "found" || status == "missing"); + } +} diff --git a/src/schema/labels.rs b/src/schema/labels.rs index a20ab75a..1bef88e1 100644 --- a/src/schema/labels.rs +++ b/src/schema/labels.rs @@ -22,6 +22,21 @@ use crate::types::TaskSource; pub(crate) const fn source_label_for(source: TaskSource, schema_version: u32) -> &'static str { match schema_version { 1 => super::v1::source_label(source), - _ => super::v2::source_label(source), + 2 => super::v2::source_label(source), + _ => super::v3::source_label(source), } } + +/// Build a task's fully-qualified name: `:#`. +/// +/// The `#` boundary separates the colon-joined structured prefix +/// (`scope:kind`, both colon-free) from the verbatim task name, which may +/// itself contain `:` (e.g. an npm script `fmt:update`). Consumers split +/// once on `#`: everything after is the name, unescaped. Centralised here +/// so `why` and `doctor` can't drift apart on the format. +pub(crate) fn fqn(source: TaskSource, name: &str, schema_version: u32) -> String { + format!( + "root:{kind}#{name}", + kind = source_label_for(source, schema_version) + ) +} diff --git a/src/schema/mod.rs b/src/schema/mod.rs index 96f24789..a52f9868 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -24,10 +24,12 @@ //! convention (filename-style → tool names) was a rename, so it moved //! from v1 to v2. +pub(crate) mod doctor_v3; pub(crate) mod labels; pub(crate) mod project; pub(crate) mod v1; pub(crate) mod v2; +pub(crate) mod v3; // Re-export so callers write `crate::schema::Project` rather than // `crate::schema::project::Project`. The inner module stays public @@ -35,8 +37,13 @@ pub(crate) mod v2; // builder methods directly without going through this shim. pub(crate) use project::Project; -/// Highest JSON schema version this binary can produce. Increments on -/// any breaking change to the serialized contract. +/// Highest JSON schema version `list` can produce (and the version the +/// flat [`project::Project`] shape serves). Increments on any breaking +/// change to that serialized contract. +/// +/// Surfaces version independently: `doctor` is at +/// [`DOCTOR_CURRENT_VERSION`] and `why` at [`WHY_CURRENT_VERSION`]; +/// `list` stays here until a v3 contract for it exists. /// /// **v2** — source labels standardized to tool names (`"just"`, /// `"bacon"`, `"make"`, `"turbo"`, `"deno"`, `"task"`, `"mise"`). @@ -49,9 +56,28 @@ pub(crate) use project::Project; /// `--schema-version=1`. pub(crate) const CURRENT_VERSION: u32 = 2; -/// Validate that `requested` is a schema version this binary can produce. -/// Returns the version unchanged on success so callers can chain it -/// directly into the builder. +/// Highest JSON schema version `doctor` can produce. +/// +/// **v3** — structured diagnostic inventory ([`doctor_v3`]): +/// `invocation`/`environment`/`runner` provenance, per-ecosystem +/// decisions with confidence, first-class `sources`, `fqn`-keyed tasks, +/// PATH-probed `tools`, duplicate-name `conflicts`, flattened +/// `diagnostics`, and a self-describing `resolution` policy block. +pub(crate) const DOCTOR_CURRENT_VERSION: u32 = 3; + +/// Highest JSON schema version `why` can produce. +/// +/// **v3** — structured report: candidates become `{task, match}` pairs +/// carrying identity (`fqn`, `provider`, `kind`, `source`, +/// `source_pointer`), resolution data (`definition`, `resolved`, `cwd`, +/// `aliases`, `dependencies`), and the match/decision breakdown that +/// mirrors the run-time selection key. Cargo alias tasks are labeled +/// `"cargo-alias"` (see [`v3`]). +pub(crate) const WHY_CURRENT_VERSION: u32 = 3; + +/// Validate that `requested` is a schema version `doctor`/`list` can +/// produce. Returns the version unchanged on success so callers can +/// chain it directly into the builder. /// /// # Errors /// @@ -67,9 +93,47 @@ pub(crate) fn validate_schema_version(requested: u32) -> anyhow::Result { Ok(requested) } +/// Validate that `requested` is a schema version `doctor` can produce. +/// +/// # Errors +/// +/// Returns `Err` when `requested == 0` or `requested > +/// DOCTOR_CURRENT_VERSION`, advertising the doctor-specific range. +pub(crate) fn validate_doctor_schema_version(requested: u32) -> anyhow::Result { + if requested == 0 || requested > DOCTOR_CURRENT_VERSION { + anyhow::bail!( + "unsupported --schema-version {requested}; `runner doctor` speaks 1..={DOCTOR_CURRENT_VERSION}", + ); + } + Ok(requested) +} + +/// Canonical public URL of a committed output schema. +pub(crate) fn schema_url(command: &str, version: u32) -> String { + format!("https://kjanat.github.io/schemas/{command}.v{version}.schema.json") +} + +/// Validate that `requested` is a schema version `why` can produce. +/// +/// # Errors +/// +/// Returns `Err` when `requested == 0` or `requested > +/// WHY_CURRENT_VERSION`, advertising the why-specific supported range. +pub(crate) fn validate_why_schema_version(requested: u32) -> anyhow::Result { + if requested == 0 || requested > WHY_CURRENT_VERSION { + anyhow::bail!( + "unsupported --schema-version {requested}; `runner why` speaks 1..={WHY_CURRENT_VERSION}", + ); + } + Ok(requested) +} + #[cfg(test)] mod tests { - use super::{CURRENT_VERSION, labels::source_label_for, validate_schema_version}; + use super::{ + CURRENT_VERSION, DOCTOR_CURRENT_VERSION, WHY_CURRENT_VERSION, labels::source_label_for, + validate_doctor_schema_version, validate_schema_version, validate_why_schema_version, + }; use crate::types::TaskSource; #[test] @@ -104,9 +168,10 @@ mod tests { #[test] fn current_version_matches_v2_labels() { - // Regression guard: `CURRENT_VERSION` and the v2 module must - // stay in lock-step. If a future v3 lands, this test moves to - // assert against `v3::source_label` and `CURRENT_VERSION = 3`. + // Regression guard: `CURRENT_VERSION` (doctor/list) and the v2 + // module must stay in lock-step until their v3 contracts are + // reviewed and implemented; `why` versions independently via + // `WHY_CURRENT_VERSION`. assert_eq!(CURRENT_VERSION, 2); assert_eq!( source_label_for(TaskSource::Justfile, CURRENT_VERSION), @@ -114,6 +179,26 @@ mod tests { ); } + #[test] + fn why_version_matches_v3_labels() { + // v3's single label divergence: cargo aliases name the + // mechanism, freeing `provider` to carry `"cargo"`. + assert_eq!(WHY_CURRENT_VERSION, 3); + assert_eq!( + source_label_for(TaskSource::CargoAliases, WHY_CURRENT_VERSION), + "cargo-alias" + ); + // Everything else inherits v2 unchanged. + assert_eq!( + source_label_for(TaskSource::Justfile, WHY_CURRENT_VERSION), + "just" + ); + assert_eq!( + source_label_for(TaskSource::PackageJson, WHY_CURRENT_VERSION), + "package.json" + ); + } + #[test] fn validate_schema_version_accepts_supported_range() { assert_eq!(validate_schema_version(1).unwrap(), 1); @@ -132,5 +217,40 @@ mod tests { msg.contains("1..=2"), "error should advertise the supported range: {msg}", ); + + // doctor/list do not speak v3 yet — only `why` does. + let err = validate_schema_version(3).expect_err("doctor/list must reject v3"); + assert!(format!("{err}").contains("1..=2")); + } + + #[test] + fn validate_doctor_schema_version_spans_one_through_three() { + assert_eq!(DOCTOR_CURRENT_VERSION, 3); + assert_eq!(validate_doctor_schema_version(1).unwrap(), 1); + assert_eq!(validate_doctor_schema_version(2).unwrap(), 2); + assert_eq!(validate_doctor_schema_version(3).unwrap(), 3); + + let err = validate_doctor_schema_version(0).expect_err("v0 must error"); + assert!(format!("{err}").contains("unsupported")); + let err = validate_doctor_schema_version(4).expect_err("future versions must error"); + assert!( + format!("{err}").contains("1..=3"), + "error should advertise the doctor range", + ); + } + + #[test] + fn validate_why_schema_version_spans_one_through_three() { + assert_eq!(validate_why_schema_version(1).unwrap(), 1); + assert_eq!(validate_why_schema_version(2).unwrap(), 2); + assert_eq!(validate_why_schema_version(3).unwrap(), 3); + + let err = validate_why_schema_version(0).expect_err("v0 must error"); + assert!(format!("{err}").contains("unsupported")); + let err = validate_why_schema_version(4).expect_err("future versions must error"); + assert!( + format!("{err}").contains("1..=3"), + "error should advertise the why range", + ); } } diff --git a/src/schema/project.rs b/src/schema/project.rs index bcf6d4fa..a844d41d 100644 --- a/src/schema/project.rs +++ b/src/schema/project.rs @@ -523,13 +523,14 @@ const PATH_PROBE_PMS: [PackageManager; 4] = [ ]; /// 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>, +/// shim classification when requested. Shared with the v3 doctor +/// builder ([`super::doctor_v3`]), hence `pub(super)`. +pub(super) struct ProbeSignals { + pub(super) path_probe: BTreeMap<&'static str, Option>, + pub(super) volta_shims: BTreeMap<&'static str, VoltaShimInfo>, } -fn probe_signals(root: &std::path::Path, resolve_shims: bool) -> ProbeSignals { +pub(super) fn probe_signals(root: &std::path::Path, resolve_shims: bool) -> ProbeSignals { use std::env; use std::thread; diff --git a/src/schema/v3.rs b/src/schema/v3.rs new file mode 100644 index 00000000..3c6bdce1 --- /dev/null +++ b/src/schema/v3.rs @@ -0,0 +1,21 @@ +//! JSON schema **v3** — source labels for the structured reports. +//! +//! v3 applies to `runner why --json` ([`super::WHY_CURRENT_VERSION`]) +//! and `runner doctor --json` ([`super::DOCTOR_CURRENT_VERSION`]); +//! `list` remains capped at [`super::CURRENT_VERSION`] until a v3 +//! contract for it exists. The one label change: cargo alias tasks report +//! `"cargo-alias"` instead of `"cargo"`, so the `kind` field names the +//! *mechanism* (an `[alias]` table entry) rather than colliding with the +//! `provider` field, which already carries `"cargo"`. Every other label +//! defers to the frozen v2 table. + +use crate::types::TaskSource; + +/// v3 source label for a given [`TaskSource`]. Only +/// [`TaskSource::CargoAliases`] diverges from v2; see module docs. +pub(crate) const fn source_label(source: TaskSource) -> &'static str { + match source { + TaskSource::CargoAliases => "cargo-alias", + _ => super::v2::source_label(source), + } +} diff --git a/src/tool/bacon.rs b/src/tool/bacon.rs index 2d879bf7..a97cfe5e 100644 --- a/src/tool/bacon.rs +++ b/src/tool/bacon.rs @@ -25,15 +25,12 @@ pub(crate) fn detect(dir: &Path) -> bool { /// Extract job names with optional descriptions, sorted alphabetically. /// -/// Prefers `bacon --list-jobs` (fast path) when the binary is on `PATH`; the -/// CLI's view is the source of truth — it merges bacon's baked-in jobs -/// (`check`, `clippy`, `test`, …) with whatever `bacon.toml` declares, -/// which is exactly what `bacon ` will actually run. Falls back to -/// parsing `bacon.toml` directly if the binary is missing or its output -/// can't be parsed; the fallback is project-local-only by necessity. +/// Prefers `bacon --list-jobs` (the source of truth — it merges bacon's +/// baked-in jobs with whatever `bacon.toml` declares), falling back to +/// parsing `bacon.toml` directly when the binary is missing or its +/// output won't parse. /// -/// Jobs whose names start with `_` are treated as private and hidden, -/// mirroring the just-style convention. +/// Jobs whose names start with `_` are hidden (just-style convention). pub(crate) fn extract_tasks(dir: &Path) -> anyhow::Result)>> { if let Some(tasks) = extract_tasks_with_bacon(dir) { return Ok(tasks); @@ -62,23 +59,10 @@ fn extract_tasks_with_bacon(dir: &Path) -> Option)>> parse_list_jobs_table(&output.stdout) } -/// Parse the ASCII-bordered table emitted by `bacon --list-jobs`. -/// -/// The format (bacon 3.20–3.22) is: -/// -/// ```text -/// ┌──────────┬─────────────...┐ -/// │ job │command │ -/// ├──────────┼─────────────...┤ -/// │ check │cargo check │ -/// │ ... │... │ -/// └──────────┴─────────────...┘ -/// default job: check -/// ``` -/// -/// ANSI styling escapes wrap individual cells; we strip the standard CSI -/// `m` form (color/bold) before splitting on the unicode `│` separator. The -/// header row `job│command` is detected and skipped by literal cell match. +/// Parse the ASCII-bordered `job │ command` table from `bacon +/// --list-jobs`. ANSI styling is stripped (CSI `m` form) before +/// splitting on the unicode `│` separator; the `job` header row is +/// skipped by literal cell match. fn parse_list_jobs_table(stdout: &[u8]) -> Option)>> { let stripped = strip_csi_m(&String::from_utf8_lossy(stdout)); let mut tasks: Vec<(String, Option)> = Vec::new(); @@ -115,10 +99,8 @@ fn parse_list_jobs_table(stdout: &[u8]) -> Option)>> (!tasks.is_empty()).then_some(tasks) } -/// Strip CSI `m` (SGR — color/bold/style) escape sequences. Custom over a -/// regex dep because the rest of this crate's TOML/CLI parsers don't pull -/// `regex` either; the form we care about is narrow (`\x1b[…m`) and the -/// hand-rolled scanner avoids a 1 MB dependency for one helper. +/// Strip CSI `m` (SGR color/bold/style) escape sequences. Hand-rolled — +/// the form is narrow (`\x1b[…m`) and doesn't justify a `regex` dep. fn strip_csi_m(s: &str) -> String { let mut out = String::with_capacity(s.len()); let mut chars = s.chars().peekable(); diff --git a/src/tool/cargo_aliases.rs b/src/tool/cargo_aliases.rs index 6cb43b20..664d988d 100644 --- a/src/tool/cargo_aliases.rs +++ b/src/tool/cargo_aliases.rs @@ -173,6 +173,16 @@ fn merge_alias_tables(paths: &[PathBuf]) -> anyhow::Result, map: &HashMap>) -> let Some(expansion) = map.get(&head) else { return tokens; }; - // Built-ins resolve to a real cargo subcommand whose name happens to - // also be a key (`r → run`, and `run` is not in the map). The Some() - // guard above already short-circuits there. Cycles are the genuine - // case for this set. + // Canonical subcommands are self-entries (`run → [run]`), so the + // cycle guard — not the Some() check — terminates them: the head + // is revisited on the next pass and we bail with the expansion so + // far. Genuine alias cycles bottom out the same way. if !visited.insert(head) { return tokens; } diff --git a/src/tool/deno.rs b/src/tool/deno.rs index 0a282dd5..71374c77 100644 --- a/src/tool/deno.rs +++ b/src/tool/deno.rs @@ -156,8 +156,13 @@ fn within_boundary(path: &Path, boundary: Option<&Path>) -> bool { boundary.is_none_or(|boundary| path == boundary || path.starts_with(boundary)) } -/// Parse task names from `deno.json` / `deno.jsonc`. -pub(crate) fn extract_tasks(dir: &Path) -> anyhow::Result> { +/// Parse task names and descriptions from `deno.json` / `deno.jsonc`. +/// +/// Handles both the string form (`"build": "vite build"`) and the object +/// form (`"build": { "command": "...", "description": "..." }`). Sorted +/// by name for deterministic output. The self-exec path re-parses the +/// config for `command` / `dependencies` when it needs them. +pub(crate) fn extract_tasks(dir: &Path) -> anyhow::Result)>> { #[derive(Deserialize)] struct Partial { tasks: Option>, @@ -169,7 +174,20 @@ pub(crate) fn extract_tasks(dir: &Path) -> anyhow::Result> { .with_context(|| format!("failed to read {}", path.display()))?; let d = json5::from_str::(&content) .with_context(|| format!("{} is not valid JSON/JSONC", path.display()))?; - Ok(d.tasks.map_or_else(Vec::new, |t| t.into_keys().collect())) + let mut tasks: Vec<(String, Option)> = d.tasks.map_or_else(Vec::new, |t| { + t.into_iter() + .map(|(name, value)| { + // String form carries no description; object form may. + let description = value + .get("description") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + (name, description) + }) + .collect() + }); + tasks.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(tasks) } /// `deno task [args...]` @@ -239,10 +257,40 @@ mod tests { ) .expect("deno.jsonc should be written"); - let mut tasks = extract_tasks(dir.path()).expect("deno tasks should parse"); - tasks.sort_unstable(); + let tasks = extract_tasks(dir.path()).expect("deno tasks should parse"); + + assert_eq!( + tasks, + [("build".to_string(), None), ("test".to_string(), None),] + ); + } - assert_eq!(tasks, ["build", "test"]); + #[test] + fn extract_tasks_reads_object_form_descriptions() { + let dir = TempDir::new("deno-task-descriptions"); + fs::write( + dir.path().join("deno.json"), + r#"{ + "tasks": { + "build": { "command": "vite build", "description": "Bundle for production" }, + "dev": "vite" + } +}"#, + ) + .expect("deno.json should be written"); + + let tasks = extract_tasks(dir.path()).expect("deno tasks should parse"); + + assert_eq!( + tasks, + [ + ( + "build".to_string(), + Some("Bundle for production".to_string()) + ), + ("dev".to_string(), None), + ] + ); } #[test] diff --git a/src/tool/deno_exec.rs b/src/tool/deno_exec.rs new file mode 100644 index 00000000..597ef334 --- /dev/null +++ b/src/tool/deno_exec.rs @@ -0,0 +1,150 @@ +//! Deno-specific layer over the in-process shell ([`super::shell`]). +//! +//! Classifies `deno.json` tasks for self-execution and runs the eligible +//! ones without the `deno` binary. The shell grammar/execution itself +//! lives in [`super::shell`]; this module only adds deno's task model +//! (object vs string form, `cwd`, `dependencies`, `deno` invocation). + +use std::collections::HashMap; +use std::path::Path; + +use anyhow::Result; +use serde::Deserialize; + +/// A deno task classified for self-execution. +#[derive(Debug)] +pub(crate) struct DenoTaskPlan { + /// The task's command string (object `command` or bare-string form). + command: String, + /// Optional per-task working directory (object `cwd`), resolved by + /// [`run`] relative to the base cwd. + cwd: Option, + /// Whether the task declares `dependencies` (unsupported in v1). + has_dependencies: bool, + /// Whether the command invokes `deno`, which needs the binary even + /// through the embedded shell. + invokes_deno: bool, +} + +impl DenoTaskPlan { + /// Self-executable in v1: a leaf command that neither declares + /// `dependencies` nor invokes `deno`. + pub(crate) const fn self_executable(&self) -> bool { + !self.has_dependencies && !self.invokes_deno + } +} + +/// Classify `task` from the deno config at `config_path`. +/// +/// Returns `None` when the config can't be read/parsed, the task is +/// absent, or it has no command body (a pure-`dependencies` task). +pub(crate) fn plan(config_path: &Path, task: &str) -> Option { + #[derive(Deserialize)] + struct Partial { + tasks: Option>, + } + let content = std::fs::read_to_string(config_path).ok()?; + let parsed = json5::from_str::(&content).ok()?; + let value = parsed.tasks?.remove(task)?; + + let (command, cwd, has_dependencies) = match value { + serde_json::Value::String(command) => (command, None, false), + serde_json::Value::Object(map) => { + let command = map + .get("command") + .and_then(serde_json::Value::as_str)? + .to_string(); + let cwd = map + .get("cwd") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + let has_dependencies = map + .get("dependencies") + .and_then(serde_json::Value::as_array) + .is_some_and(|deps| !deps.is_empty()); + (command, cwd, has_dependencies) + } + _ => return None, + }; + + Some(DenoTaskPlan { + invokes_deno: super::shell::mentions_program(&command, "deno"), + command, + cwd, + has_dependencies, + }) +} + +/// Run a self-executable deno task in-process, returning its exit code. +/// +/// `args` are appended to the command, matching `deno task +/// `. The per-task `cwd` resolves relative to `cwd` (the +/// invocation root). +pub(crate) fn run(plan: &DenoTaskPlan, args: &[String], cwd: &Path) -> Result { + let effective_cwd = plan + .cwd + .as_ref() + .map_or_else(|| cwd.to_path_buf(), |rel| cwd.join(rel)); + super::shell::run(&plan.command, args, &effective_cwd) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tool::test_support::TempDir; + use std::fs; + + fn write_config(dir: &TempDir, body: &str) -> std::path::PathBuf { + let path = dir.path().join("deno.json"); + fs::write(&path, body).expect("deno.json should be written"); + path + } + + #[test] + fn plan_string_form_is_self_executable() { + let dir = TempDir::new("deno-exec-string"); + let path = write_config(&dir, r#"{ "tasks": { "greet": "echo hi" } }"#); + + let plan = plan(&path, "greet").expect("task should plan"); + assert!(plan.self_executable()); + } + + #[test] + fn plan_flags_deno_invocation_as_not_self_executable() { + let dir = TempDir::new("deno-exec-denoword"); + let path = write_config(&dir, r#"{ "tasks": { "build": "deno run -A build.ts" } }"#); + + let plan = plan(&path, "build").expect("task should plan"); + assert!(!plan.self_executable()); + } + + #[test] + fn plan_flags_dependencies_as_not_self_executable() { + let dir = TempDir::new("deno-exec-deps"); + let path = write_config( + &dir, + r#"{ "tasks": { "all": { "command": "echo done", "dependencies": ["build"] } } }"#, + ); + + let plan = plan(&path, "all").expect("task should plan"); + assert!(!plan.self_executable()); + } + + #[test] + fn plan_returns_none_for_missing_task() { + let dir = TempDir::new("deno-exec-missing"); + let path = write_config(&dir, r#"{ "tasks": { "greet": "echo hi" } }"#); + + assert!(plan(&path, "absent").is_none()); + } + + #[test] + fn run_executes_task_without_deno() { + let dir = TempDir::new("deno-exec-run"); + let path = write_config(&dir, r#"{ "tasks": { "ok": "exit 0" } }"#); + + let plan = plan(&path, "ok").expect("task should plan"); + let code = run(&plan, &[], dir.path()).expect("self-exec should run"); + assert_eq!(code, 0); + } +} diff --git a/src/tool/mise.rs b/src/tool/mise.rs index fe5c5717..b731ee84 100644 --- a/src/tool/mise.rs +++ b/src/tool/mise.rs @@ -684,8 +684,7 @@ mod tests { #[test] fn cli_output_extracts_tasks_under_project_root() { - // Captured shape from `mise tasks --json` against the - // dprint-plugin-shfmt repo (see issue #23 follow-up). + // Captured shape from a real `mise tasks --json` payload. let dir = TempDir::new("mise-cli-payload"); let project = dir .path() diff --git a/src/tool/mod.rs b/src/tool/mod.rs index 0c341496..4fb42e2e 100644 --- a/src/tool/mod.rs +++ b/src/tool/mod.rs @@ -26,6 +26,8 @@ pub(crate) mod cargo_pm; pub(crate) mod composer; /// Deno JavaScript/TypeScript runtime (`deno.json` / `deno.jsonc`). pub(crate) mod deno; +/// In-process execution of deno tasks via `deno_task_shell` (no deno binary). +pub(crate) mod deno_exec; /// Shared filesystem helpers for tool modules. pub(crate) mod files; /// Go modules (`go.mod`). @@ -56,6 +58,9 @@ pub(crate) mod poetry; pub(crate) mod program; /// Shared Python tooling helpers. pub(crate) mod python; +/// Cross-platform in-process shell runner (`deno_task_shell`), reusable +/// for any tool whose task bodies are shell command strings. +pub(crate) mod shell; /// Turborepo monorepo build system (`turbo.json` / `turbo.jsonc`). pub(crate) mod turbo; /// uv, a fast Python package manager (`uv.lock`). diff --git a/src/tool/node.rs b/src/tool/node.rs index 755d46d6..0b60cbf9 100644 --- a/src/tool/node.rs +++ b/src/tool/node.rs @@ -43,10 +43,8 @@ pub(crate) fn find_manifest_upwards(dir: &Path) -> Option { /// (within the VCS root) declares a workspace via `pnpm-workspace.yaml`, /// `lerna.json`, or a `package.json` carrying a `"workspaces"` key. /// -/// This is the *workspace-root-aware* guard for upward script discovery: -/// a manifest-less subdirectory only adopts a parent manifest's scripts -/// when it provably belongs to a workspace, so an unrelated ancestor -/// `package.json` from some outer project is never silently picked up. +/// Guards upward script discovery: a manifest-less subdirectory adopts a +/// parent's scripts only when it provably belongs to that workspace. pub(crate) fn within_workspace_upwards(dir: &Path) -> bool { files::find_in_ancestors(dir, |ancestor| { if ancestor.join("pnpm-workspace.yaml").is_file() || ancestor.join("lerna.json").is_file() { @@ -108,13 +106,10 @@ fn detect_pm(package_json: Option) -> Option { } /// Parse a Corepack-style `name@version` spec into a [`PackageManager`] and -/// optional version string. Returns `None` for unknown names or for a -/// trailing-`@` typo (`"pnpm@"`) — the `@` separator with an empty RHS -/// is an explicit "version intended but missing" signal, distinct from -/// a bare `"pnpm"` (no `@` at all). Bare names are still accepted with -/// `None` version; only the malformed `name@` form is rejected so the -/// surrounding [`UnparseablePackageManager`] warning surfaces the typo -/// instead of silently dropping the version constraint. +/// optional version string. Bare `"pnpm"` parses with `None` version; +/// the malformed `name@` form (empty version) is rejected so the +/// [`UnparseablePackageManager`] warning surfaces the typo rather than +/// silently dropping the constraint. Unknown names return `None`. fn parse_package_manager_spec(spec: Option<&str>) -> Option<(PackageManager, Option)> { let raw = spec?.trim(); let (name, version) = match raw.split_once('@') { @@ -197,26 +192,16 @@ pub(crate) struct ManifestPmDecl { /// field is present or parseable. /// /// Entries naming a PM that cannot dispatch `package.json` scripts -/// (e.g. `cargo`) are dropped at parse time so they never reach the -/// resolver as an apparently-valid manifest declaration. Without this -/// filter, an entry like `{"name": "cargo"}` would surface as a winning -/// `devEngines` decision and then fail at spawn time with the opaque -/// "cannot run scripts" branch. +/// (e.g. `cargo`) are dropped at parse time so a non-script PM never +/// wins as a manifest declaration and fails later at spawn time. pub(crate) fn detect_pm_from_manifest(dir: &Path) -> Option { let parsed = parse_package_json(dir)?; - // `packageManager` is authoritative when present — that's the - // Corepack contract this field carries. Trim and treat - // whitespace-only as "not set" (round-trips trailing-newline - // editor mishaps). When the trimmed value is non-empty, the - // legacy field is the user's explicit intent and must short-circuit - // here: a present-but-unparseable spec (typo like `"pnpmm@9"`, - // non-script PM like `"cargo@1"`, malformed `"@9"`) MUST NOT let - // `devEngines` win silently — that would substitute a different PM - // than what the user wrote. Returning `None` here drops the - // resolver to step 6 (lockfile) / step 7 (PATH probe), which is - // closer to Corepack's semantics than synthesising a `devEngines` - // decision the user never declared. + // `packageManager` is authoritative when present (the Corepack + // contract). A non-empty value short-circuits here even when + // unparseable: letting `devEngines` win would substitute a PM the + // user never wrote, so we return `None` and drop to the lockfile / + // PATH-probe steps instead. Whitespace-only counts as "not set". let pm_spec = parsed .package_manager .as_deref() @@ -273,11 +258,9 @@ pub(crate) fn detect_pm_from_manifest(dir: &Path) -> Option { } /// Parse a `devEngines.packageManager` entry's `name` field, accepting -/// only PMs that can actually run `package.json` scripts. Other -/// ecosystems (Cargo, uv, etc.) are valid `PackageManager` variants but -/// reaching the script-dispatch path with them yields the opaque -/// "cannot run scripts" error from `build_run_command`, so reject them -/// at parse time instead. +/// only PMs that can run `package.json` scripts. Non-script ecosystems +/// (Cargo, uv, …) are rejected at parse time rather than failing later +/// on the dispatch path. fn script_dispatching_pm(label: &str) -> Option { let pm = PackageManager::from_label(label)?; matches!( @@ -389,9 +372,8 @@ pub(crate) fn check_version_constraint(pm: PackageManager, declared: &str) -> Ve /// non-zero. /// /// Spawns via `tool::program::command` so Windows `npm.cmd`/`pnpm.cmd`/ -/// `yarn.cmd` shims resolve through `PATHEXT`. Without this, the bug -/// fixed for dispatch in 0.8.1 (#21) would silently downgrade -/// `devEngines.version` enforcement to `Unverifiable` on Windows. +/// `yarn.cmd` shims resolve through `PATHEXT`; otherwise +/// `devEngines.version` enforcement silently degrades to `Unverifiable`. fn installed_version(pm: PackageManager) -> Option { let out = program::command(pm.label()) .arg("--version") @@ -770,12 +752,9 @@ mod tests { #[test] fn detect_pm_from_manifest_blocks_dev_engines_when_package_manager_unparseable() { - // Regression: a present-but-unparseable `packageManager` value - // (e.g. the user typo'd `pnpm`) used to silently fall through - // to `devEngines`, substituting a PM the user never declared. - // Per Corepack semantics the legacy field is authoritative; if - // it can't parse we return `None` so the resolver drops to the - // lockfile/PATH-probe path instead. + // An unparseable `packageManager` must not elevate `devEngines`: + // the legacy field is authoritative, so a parse failure returns + // `None` rather than substituting the devEngines PM. use super::detect_pm_from_manifest; let dir = TempDir::new("node-manifest-decl-unparseable-pm-field"); diff --git a/src/tool/passthrough.rs b/src/tool/passthrough.rs index 5f38bd90..0f9a1ebc 100644 --- a/src/tool/passthrough.rs +++ b/src/tool/passthrough.rs @@ -57,32 +57,19 @@ const CANDIDATES: &[(TaskRunner, &str, Option<&str>)] = &[ ]; /// Conservative passthrough matcher: requires `command` to be exactly -/// ` [run_subcommand] [args…]`, rejecting any tail that -/// contains a shell-active token. -/// -/// The check is deliberately strict in the safe direction — false -/// negatives leave a script visible in completion as a separate -/// candidate, which is the same outcome we have today. False positives -/// would silently swallow a real script and need to be avoided. +/// ` [run_subcommand] [args…]`, rejecting any tail with a +/// shell-active token. Strict in the safe direction — a false negative +/// just leaves a script visible; a false positive silently swallows one. fn simple_passthrough( name: &str, command: &str, binary: &str, run_subcommand: Option<&str>, ) -> bool { - // Reject anything that spans multiple shell lines. `split_whitespace` - // treats `\n` and `\r` as ordinary separators, so a script like - // `"just build\necho owned"` would otherwise tokenise to - // `["just", "build", "echo", "owned"]` and look like a thin - // passthrough — the trailing `echo` is a separate command, not an - // argument forwarded to `just`. Bash also accepts `\r\n` on Windows - // editors so both characters get the early bail. - // - // Other control operators (`;`, `&&`, `||`, `|`) don't need an - // early check: spaced forms surface as tokens that `is_shell_active` - // rejects (substring `;`/`&`/`|`), and glued forms get rejected at - // the binary/name token comparison or by the same any-position - // substring scan in `is_shell_active`. + // Reject multi-line scripts: `split_whitespace` treats `\n`/`\r` as + // separators, so `"just build\necho owned"` would tokenise like a + // thin passthrough while hiding the trailing command. Other operators + // are caught downstream by `is_shell_active`. if command.contains('\n') || command.contains('\r') { return false; } @@ -98,22 +85,11 @@ fn simple_passthrough( if tokens.next() != Some(name) { return false; } - // After binary + (optional run subcommand) + name, only *flags* may - // remain — anything positional changes behavior the wrapper would - // otherwise lose at dispatch time. Examples that previously slipped - // through and got silently dropped: - // - // * `make build clean` — two make targets, the wrapper would run - // only `build` if dispatched as a thin passthrough. - // * `just build release` — recipe parameter for `just`, lost on - // dispatch. - // * `nx run build extra` — extra positional that nx would forward. - // - // Flags (tokens that start with `-`: `-x`, `--flag`, `--flag=val`, - // the literal `--` separator) are the only safe tail tokens; their - // presence doesn't change which target/recipe `just`/`make`/etc. - // ends up running, they just configure how. `is_shell_active` still - // applies to catch glued shell meta-chars even inside flags. + // After binary + (optional run subcommand) + name, only flags may + // remain: a positional changes which target/recipe runs (e.g. + // `make build clean`, `just build release`) and would be lost on a + // thin dispatch. Flags only configure how, so they're safe — but + // `is_shell_active` still screens for glued meta-chars inside them. tokens.all(|token| token.starts_with('-') && !is_shell_active(token)) } @@ -127,47 +103,32 @@ fn simple_passthrough( /// `arg > out` respectively, so a passthrough wrapper that contains /// them is not actually a thin dispatch. fn is_shell_active(token: &str) -> bool { - // Expansion / substitution — `$VAR`, `$(cmd)`, `$((expr))`, - // `` `cmd` ``, and Windows `cmd.exe` `%VAR%` expansion (`package.json` - // scripts spawn through the user's shell, which on Windows is - // typically `cmd.exe`). + // Expansion / substitution: `$VAR`, `$(cmd)`, backticks, and Windows + // `cmd.exe` `%VAR%` (scripts spawn through the user's shell). if token.contains('$') || token.contains('`') || token.contains('%') { return true; } - // Redirects (`>`, `<`, `>>`, `<<`, `>&`, `&>`, `1>foo`, `2>&1`, …) - // and control operators (`&&`, `||`, `|`, `|&`, `;`, `;;`, `;&`, - // backgrounding `cmd&`). Substring-matching `&` subsumes `&&`, - // `>&`, `|&`, and trailing background; `|` subsumes `||` and - // `|&`; `;` subsumes the compound forms. Any one of these in any - // position means the shell will do real work, so we bail. + // Redirects and control operators, matched substring-anywhere: `&` + // subsumes `&&`/`>&`/backgrounding, `|` subsumes `||`/`|&`, `;` the + // compound forms. Any occurrence means the shell does real work. if token .chars() .any(|c| matches!(c, '>' | '<' | '&' | '|' | ';')) { return true; } - // Pathname / brace expansion — bash expands these *before* exec, so - // a script body like `just build src/*.js` is no longer a thin - // dispatch: the shell expands the glob into a file list and - // forwards that to `just`. Reject substring-anywhere so glued - // forms like `--filter=name{a,b}` (which expands to two args - // `--filter=namea --filter=nameb`) also get caught. - // - // `{` / `}` join this set because the only realistic non-expansion - // use of curly braces inside a single shell token is `${VAR}`, - // which is already caught by the `$` check above; bare `{a,b}` - // syntax means brace expansion. + // Glob / brace expansion, which the shell resolves before exec. + // Substring-anywhere catches glued forms like `--filter=name{a,b}`; + // `{`/`}` are here since `${VAR}` is already caught by `$` above. if token .chars() .any(|c| matches!(c, '*' | '?' | '[' | ']' | '{' | '}')) { return true; } - // `(`, `)`, `!` stay exact-match — they're only shell-active as - // standalone tokens (`(subshell)` requires whitespace, `! cmd` - // requires leading `!`). Substring-matching would over-reject - // benign arg literals like `--filter=name(v1)`, `arg!`, or - // a `package.json` script that quotes a path with parens. + // `(`, `)`, `!` are exact-match only — shell-active solely as + // standalone tokens, so substring-matching would over-reject benign + // arg literals like `--filter=name(v1)` or `arg!`. matches!(token, "!" | "(" | ")") } diff --git a/src/tool/shell.rs b/src/tool/shell.rs new file mode 100644 index 00000000..b01878cf --- /dev/null +++ b/src/tool/shell.rs @@ -0,0 +1,96 @@ +//! In-process command execution via the cross-platform shell from +//! [`deno_task_shell`]. +//! +//! A reusable engine for running a shell command string without a system +//! shell or any specific tool binary: sequential lists, `&&`/`||`, pipes, +//! env-var expansion, redirects, globs, and a set of coreutils-style +//! builtins. It is a *subset* of POSIX `sh` (not bash), and external +//! command words still resolve from `$PATH`. +//! +//! Used today by deno self-exec ([`super::deno_exec`]); any source whose +//! task bodies are shell strings (e.g. `package.json` scripts) can build +//! on it — provided that source's own semantics (env injection, local +//! `bin` dirs, lifecycle hooks) are layered on top. + +use std::collections::HashMap; +use std::ffi::OsString; +use std::path::Path; + +use anyhow::{Context, Result, anyhow}; +use deno_task_shell::{KillSignal, execute, parser}; + +/// Run `command` (with `args` appended) in `cwd`, returning the exit code. +/// +/// Errors only on a parse or runtime-construction failure; a non-zero +/// command exit is returned as the code, not an error. Inherits the +/// current process environment. +pub(crate) fn run(command: &str, args: &[String], cwd: &Path) -> Result { + let mut script = command.to_string(); + for arg in args { + let quoted = shlex::try_quote(arg).map_err(|e| anyhow!("cannot quote arg: {e}"))?; + script.push(' '); + script.push_str("ed); + } + + let list = parser::parse(&script).map_err(|e| anyhow!("failed to parse command: {e}"))?; + let env: HashMap = std::env::vars_os().collect(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to build async runtime for in-process shell")?; + + Ok(runtime.block_on(execute( + list, + env, + cwd.to_path_buf(), + HashMap::new(), + KillSignal::default(), + ))) +} + +/// Conservative check for whether `command` invokes `program` as a +/// command word: any shell token equal to `program`. Over-detection +/// (e.g. `program` appearing as an argument) is the safe direction for +/// callers gating on "needs this binary". +pub(crate) fn mentions_program(command: &str, program: &str) -> bool { + shlex::split(command) + .unwrap_or_default() + .iter() + .any(|token| token == program) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tool::test_support::TempDir; + + #[test] + fn run_executes_builtin_command() { + let dir = TempDir::new("shell-run-ok"); + let code = run("exit 0", &[], dir.path()).expect("builtin should run"); + assert_eq!(code, 0); + } + + #[test] + fn run_propagates_nonzero_exit() { + let dir = TempDir::new("shell-run-fail"); + let code = run("exit 3", &[], dir.path()).expect("builtin should run"); + assert_eq!(code, 3); + } + + #[test] + fn run_appends_quoted_args() { + // `false ` ignores args but must still parse with them + // appended and quoted; a space-bearing arg must not split. + let dir = TempDir::new("shell-run-args"); + let code = run("exit", &["7".to_string()], dir.path()).expect("should run"); + assert_eq!(code, 7); + } + + #[test] + fn mentions_program_detects_command_word() { + assert!(mentions_program("deno run -A x.ts", "deno")); + assert!(mentions_program("tsc && deno test", "deno")); + assert!(!mentions_program("echo hello", "deno")); + } +} diff --git a/src/tool/turbo.rs b/src/tool/turbo.rs index 8853257a..2f4e6e89 100644 --- a/src/tool/turbo.rs +++ b/src/tool/turbo.rs @@ -87,47 +87,18 @@ pub(crate) fn run_cmd(task: &str, args: &[String]) -> Command { /// `turbo `, optionally followed by flag tokens (e.g. `--filter web`, /// `--concurrency=4`). /// -/// The tail after the target name must consist solely of flag tokens -/// (`-x`, `--long`, `--key=value`), values immediately following a -/// non-`=` flag, or — after a bare `--` end-of-options separator — -/// args forwarded to the underlying task. The `--` separator itself -/// is recognized as a marker (POSIX/getopt convention), not as a -/// flag; turbo's own argument-forwarding pattern is `turbo run -/// -- `. Any of these reject the match — they mean the -/// script does more than just dispatch to turbo: -/// - shell control operators: `&&`, `||`, `;`, `;;`, `;&`, `;;&`, -/// `|`, `|&`, `&`, `!`, `{`, `}`, `(`, `)` -/// - redirect operators: bare `>`/`<`/`>>`/`<<`/`<<<`, combined-fd -/// `&>`/`&>>`/`>&`, fd-prefixed `2>`, `1>`, composite `2>&1`, -/// `1>&2`, `2>/dev/null`, `&>file.log` -/// - shell expansion tokens (anything containing `$` or backtick): -/// parameter expansion (`$X`, `${X}`, `${X:-def}`, `${X//a/b}`, -/// `${!X}`, `${#X}`, `${X[@]}`), special vars (`$@`, `$*`, `$#`, -/// `$?`), command substitution (`$(cmd)`, `` `cmd` ``), arithmetic -/// (`$((expr))`), and quoted forms with embedded expansion -/// (`"${X}"`) -/// - any other bare positional that isn't consuming a flag's value +/// The tail after the target name must be flag tokens (`-x`, `--long`, +/// `--key=value`), values following a non-`=` flag, or args after a +/// bare `--` end-of-options separator (turbo's `turbo run -- +/// ` forwarding pattern). Any shell control operator, redirect, +/// or expansion token (`$`/backtick) rejects the match — they mean the +/// script does more than dispatch to turbo. /// -/// This is purely a textual heuristic on the script body. Indirect -/// invocations (`npx turbo run build`, `pnpm exec turbo run build`) are -/// intentionally not matched: a wrapper that goes through a package-manager -/// shim is a step removed from the canonical Turborepo pattern, and matching -/// it would risk false positives for unrelated `npx`/`pnpm exec` scripts. -/// -/// Known limitations (deferred — rare in turbo dispatch scripts and -/// stricter detection would over-reject legitimate patterns): -/// - unquoted globs (`*`, `?`) following a flag are accepted because -/// `*` is legitimate in turbo filters like `@scope/*`; -/// - tilde expansion (`~/cache`) following a flag is accepted because -/// `~` is legitimate in path values like `--cache-dir`; -/// - brace expansion (`{a,b,c}`) following a flag is accepted because -/// distinguishing it from quoted JSON values is fragile; -/// - quoted multi-word arguments (`--filter "my app"`) are split -/// incorrectly by `split_whitespace` and reject via the positional -/// rule — false negative, safe direction (script stays visible); -/// - single-quoted literals containing `$` or backtick (e.g. `'$X'`) -/// are rejected even though shell-literal — extremely rare in turbo -/// scripts, false negative, safe direction. +/// Purely a textual heuristic on the script body. Indirect invocations +/// (`npx turbo run build`, `pnpm exec …`) are deliberately not matched +/// to avoid false positives on unrelated wrapper scripts. Errs toward +/// false negatives (leaving a script visible) on ambiguous tails like +/// quoted multi-word args or unquoted globs. pub(crate) fn is_self_passthrough(name: &str, command: &str) -> bool { let mut tokens = command.split_whitespace(); if tokens.next() != Some("turbo") { @@ -561,8 +532,8 @@ mod tests { #[test] fn is_self_passthrough_rejects_stderr_to_stdout_after_flag() { - // The bug CodeRabbit-reviewer #2 caught: `2>&1` was consumed as - // `--no-cache`'s value. The redirect-detection pass now rejects. + // `2>&1` must not be consumed as `--no-cache`'s value; the + // redirect-detection pass rejects it. assert!(!is_self_passthrough( "build", "turbo run build --no-cache 2>&1"