From 2d397fed9741412e0927a6803bb54f75b5b9202b Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 5 Jul 2026 03:12:22 +0200 Subject: [PATCH 1/7] feat(schema)!: collapse doctor/why/list versioning to a single v1 Not enough external adoption yet to justify carrying v1/v2/v3 compat simultaneously across doctor/why/list. Purge the old versions and treat the current shape as the sole, first version. - --schema-version now only accepts 1 (was 1..=3); doctor/why --json always emit the structured report that used to require --schema-version 3 (the v1/v2 flat shape is gone from both output paths). list/info --json keep the flat Project/TaskListView shape, now unversioned internally. - src/schema/v1.rs, v2.rs, v3.rs deleted; source-label dispatch collapses to labels::flat_source_label/structured_source_label (list vs doctor/why), no version parameter. - CURRENT_VERSION/DOCTOR_CURRENT_VERSION/WHY_CURRENT_VERSION collapse to a single schema::SCHEMA_VERSION = 1. - src/schema/doctor_v3.rs -> src/schema/doctor.rs; V3-suffixed structs across doctor.rs/why.rs drop the suffix (EcosystemV3 -> EcosystemEntry to avoid colliding with types::Ecosystem). - 10 superseded schemas/*.json deleted; the 3 kept files drop their version suffix (doctor.v3.schema.json -> doctor.schema.json, etc). Closes #80 --- CHANGELOG.md | 15 + README.md | 2 +- ...or.v3.example.json => doctor.example.json} | 4 +- ...ctor.v3.schema.json => doctor.schema.json} | 140 +++--- schemas/doctor.v1.example.json | 231 --------- schemas/doctor.v1.schema.json | 471 ------------------ schemas/doctor.v2.example.json | 231 --------- schemas/doctor.v2.schema.json | 471 ------------------ ...list.v2.example.json => list.example.json} | 2 +- .../{list.v2.schema.json => list.schema.json} | 8 +- schemas/list.v1.example.json | 173 ------- schemas/list.v1.schema.json | 92 ---- .../{why.v3.example.json => why.example.json} | 2 +- .../{why.v3.schema.json => why.schema.json} | 26 +- schemas/why.v1.example.json | 29 -- schemas/why.v1.schema.json | 192 ------- schemas/why.v2.example.json | 29 -- schemas/why.v2.schema.json | 192 ------- src/cli.rs | 20 +- src/cmd/doctor.rs | 81 +-- src/cmd/info.rs | 7 +- src/cmd/list.rs | 7 +- src/cmd/schema.rs | 233 ++++----- src/cmd/why.rs | 326 ++++-------- src/lib.rs | 65 +-- src/schema/{doctor_v3.rs => doctor.rs} | 331 ++++++------ src/schema/labels.rs | 48 +- src/schema/mod.rs | 276 ++-------- src/schema/project.rs | 152 ++---- src/schema/v1.rs | 33 -- src/schema/v2.rs | 21 - src/schema/v3.rs | 21 - 32 files changed, 613 insertions(+), 3318 deletions(-) rename schemas/{doctor.v3.example.json => doctor.example.json} (99%) rename schemas/{doctor.v3.schema.json => doctor.schema.json} (90%) delete mode 100644 schemas/doctor.v1.example.json delete mode 100644 schemas/doctor.v1.schema.json delete mode 100644 schemas/doctor.v2.example.json delete mode 100644 schemas/doctor.v2.schema.json rename schemas/{list.v2.example.json => list.example.json} (99%) rename schemas/{list.v2.schema.json => list.schema.json} (90%) delete mode 100644 schemas/list.v1.example.json delete mode 100644 schemas/list.v1.schema.json rename schemas/{why.v3.example.json => why.example.json} (98%) rename schemas/{why.v3.schema.json => why.schema.json} (93%) delete mode 100644 schemas/why.v1.example.json delete mode 100644 schemas/why.v1.schema.json delete mode 100644 schemas/why.v2.example.json delete mode 100644 schemas/why.v2.schema.json rename src/schema/{doctor_v3.rs => doctor.rs} (83%) delete mode 100644 src/schema/v1.rs delete mode 100644 src/schema/v2.rs delete mode 100644 src/schema/v3.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eb09611..54035b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,21 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - [ ] Update the `[Unreleased]` compare link to the new tag. - [ ] Create and push a signed `vX.Y.Z` tag from `master`. +### Changed + +- **Breaking:** `doctor --json` and `why --json` now always emit the + structured report (previously reachable via `--schema-version 3`); the + flat v1/v2 shape is gone from both. `--schema-version` now only accepts + `1`; `2`/`3` are rejected. + +### Removed + +- The v1/v2/v3 schema split. Not enough external adoption yet to justify + carrying three versions per surface — today's shape is the only one, + retroactively called v1. Committed schema files dropped their version + suffix (`doctor.v3.schema.json` → `doctor.schema.json`, etc.); the 10 + superseded schema/example files are deleted. + ## [0.18.1] - 2026-07-04 ### Fixed diff --git a/README.md b/README.md index 4a469a46..7310fcab 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ l -- → clippy --all-targets --all-features -- -D warnings -D clipp --explain -- Print a one-line trace describing how the package manager was resolved. Also enabled when RUNNER_EXPLAIN is set to a truthy value. --no-warnings -- Suppress all non-fatal warnings on stderr. Also enabled when RUNNER_NO_WARNINGS is set to a truthy value. -q, --quiet -- Suppress the dispatch arrow on stderr. Also enabled when RUNNER_QUIET is set to a truthy value. ---schema-version -- Pin JSON output schema version (1 or 2). Defaults to latest. Affects --json output of doctor/list/why only. +--schema-version -- Pin JSON output schema version (currently always 1). Affects --json output of doctor/list/why only. --sequential -- Run the given tasks sequentially. Conflicts with `--parallel` --parallel -- Run the given tasks in parallel. Conflicts with `--sequential` --keep-going -- Run every task in the chain regardless of failures. Conflicts with `--kill-on-fail` diff --git a/schemas/doctor.v3.example.json b/schemas/doctor.example.json similarity index 99% rename from schemas/doctor.v3.example.json rename to schemas/doctor.example.json index c057f39c..ad388d3d 100644 --- a/schemas/doctor.v3.example.json +++ b/schemas/doctor.example.json @@ -1,6 +1,6 @@ { - "$schema": "https://kjanat.github.io/runner/schemas/doctor.v3.schema.json", - "schema_version": 3, + "$schema": "https://kjanat.github.io/runner/schemas/doctor.schema.json", + "schema_version": 1, "kind": "runner.doctor", "invocation": { "argv": [ diff --git a/schemas/doctor.v3.schema.json b/schemas/doctor.schema.json similarity index 90% rename from schemas/doctor.v3.schema.json rename to schemas/doctor.schema.json index 39347931..14b77653 100644 --- a/schemas/doctor.v3.schema.json +++ b/schemas/doctor.schema.json @@ -1,8 +1,8 @@ { - "$id": "https://kjanat.github.io/runner/schemas/doctor.v3.schema.json", + "$id": "https://kjanat.github.io/runner/schemas/doctor.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { - "ConfidenceV3": { + "Confidence": { "description": "How sure the resolver is about an ecosystem's PM selection.", "oneOf": [ { @@ -27,7 +27,7 @@ } ] }, - "ConflictV3": { + "Conflict": { "description": "A task name claimed by more than one source: who wins, who is shadowed.", "type": "object", "required": [ @@ -53,7 +53,7 @@ "type": "string" }, "severity": { - "$ref": "#/$defs/SeverityV3" + "$ref": "#/$defs/Severity" }, "shadowed": { "type": "array", @@ -64,7 +64,7 @@ }, "additionalProperties": false }, - "DependencyKindV3": { + "DependencyKind": { "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": [ @@ -73,7 +73,7 @@ "task-runner" ] }, - "DiagnosticV3": { + "Diagnostic": { "description": "One detection/resolution diagnostic, flattened from the warning\nstreams.", "type": "object", "required": [ @@ -92,7 +92,7 @@ "type": "string" }, "severity": { - "$ref": "#/$defs/SeverityV3" + "$ref": "#/$defs/Severity" }, "source": { "type": [ @@ -109,8 +109,8 @@ }, "additionalProperties": false }, - "DoctorTaskV3": { - "description": "One task in the doctor inventory. Same identity scheme as `why` v3\n(`fqn`, `source_pointer`, `aliases`, `definition`, `resolved`).", + "DoctorTask": { + "description": "One task in the doctor inventory. Same identity scheme as `why`\n(`fqn`, `source_pointer`, `aliases`, `definition`, `resolved`).", "type": "object", "required": [ "aliases", @@ -189,7 +189,7 @@ }, "additionalProperties": false }, - "EcosystemDecisionV3": { + "EcosystemDecision": { "type": "object", "required": [ "confidence", @@ -198,7 +198,7 @@ ], "properties": { "confidence": { - "$ref": "#/$defs/ConfidenceV3" + "$ref": "#/$defs/Confidence" }, "reason": { "type": "string" @@ -212,7 +212,7 @@ }, "additionalProperties": false }, - "EcosystemV3": { + "EcosystemEntry": { "description": "One detected ecosystem and the PM decision made for it.", "type": "object", "required": [ @@ -224,7 +224,7 @@ ], "properties": { "decision": { - "$ref": "#/$defs/EcosystemDecisionV3" + "$ref": "#/$defs/EcosystemDecision" }, "name": { "type": "string" @@ -244,7 +244,7 @@ }, "additionalProperties": false }, - "EnvironmentV3": { + "Environment": { "description": "Host facts that influence probing and dispatch.", "type": "object", "required": [ @@ -275,7 +275,7 @@ }, "additionalProperties": false }, - "InvocationV3": { + "Invocation": { "description": "How this report came to be: the exact process invocation.", "type": "object", "required": [ @@ -300,8 +300,8 @@ }, "additionalProperties": false }, - "OverridesV3": { - "description": "Effective override stack, labels only. Provenance (cli/env/config)\nstays on the v2 surface.", + "Overrides": { + "description": "Effective override stack, labels only. Provenance (cli/env/config)\nstays on the flat `list`/`info` surface.", "type": "object", "required": [ "explain", @@ -359,7 +359,7 @@ }, "additionalProperties": false }, - "ProjectInfoV3": { + "ProjectInfo": { "description": "Project anchoring facts.", "type": "object", "required": [ @@ -385,7 +385,7 @@ }, "additionalProperties": false }, - "ResolutionPolicyV3": { + "ResolutionPolicy": { "description": "Self-description of the task-selection policy, so consumers don't\nhardcode runner's precedence rules.", "type": "object", "required": [ @@ -409,7 +409,7 @@ }, "additionalProperties": false }, - "RunnerInfoV3": { + "RunnerInfo": { "description": "The reporting binary's own identity and contract versions.", "type": "object", "required": [ @@ -426,7 +426,7 @@ "type": "string" }, "schema_versions": { - "$ref": "#/$defs/SchemaVersionsV3" + "$ref": "#/$defs/SchemaVersions" }, "version": { "type": "string" @@ -434,7 +434,7 @@ }, "additionalProperties": false }, - "SchemaVersionsV3": { + "SchemaVersions": { "description": "Latest schema version each `--json` surface speaks.", "type": "object", "required": [ @@ -461,7 +461,7 @@ }, "additionalProperties": false }, - "SeverityV3": { + "Severity": { "description": "Severity of a conflict or diagnostic. The draft's `debug`/`error`\nlevels join when something emits them.", "type": "string", "enum": [ @@ -469,7 +469,7 @@ "warning" ] }, - "SourceV3": { + "SourceEntry": { "description": "One task-source config file as a first-class object.", "type": "object", "required": [ @@ -532,7 +532,37 @@ "pyproject.toml" ] }, - "ToolProbeV3": { + "Tool": { + "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/DependencyKind" + }, + "name": { + "type": "string" + }, + "probe": { + "$ref": "#/$defs/ToolProbe" + }, + "required": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "ToolProbe": { "description": "PATH-probe outcome, tagged by `status`.", "oneOf": [ { @@ -574,40 +604,10 @@ "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.", + "title": "runner doctor --json", + "description": "JSON schema for `runner doctor --json`: structured diagnostic inventory with invocation/environment provenance, per-ecosystem decisions, sources, fqn-keyed tasks, tools, conflicts, and diagnostics.", "type": "object", "required": [ "$schema", @@ -634,66 +634,66 @@ "conflicts": { "type": "array", "items": { - "$ref": "#/$defs/ConflictV3" + "$ref": "#/$defs/Conflict" } }, "diagnostics": { "type": "array", "items": { - "$ref": "#/$defs/DiagnosticV3" + "$ref": "#/$defs/Diagnostic" } }, "ecosystems": { "type": "array", "items": { - "$ref": "#/$defs/EcosystemV3" + "$ref": "#/$defs/EcosystemEntry" } }, "environment": { - "$ref": "#/$defs/EnvironmentV3" + "$ref": "#/$defs/Environment" }, "invocation": { - "$ref": "#/$defs/InvocationV3" + "$ref": "#/$defs/Invocation" }, "kind": { "description": "Payload discriminator; always \"runner.doctor\".", "type": "string" }, "overrides": { - "$ref": "#/$defs/OverridesV3" + "$ref": "#/$defs/Overrides" }, "project": { - "$ref": "#/$defs/ProjectInfoV3" + "$ref": "#/$defs/ProjectInfo" }, "resolution": { - "$ref": "#/$defs/ResolutionPolicyV3" + "$ref": "#/$defs/ResolutionPolicy" }, "runner": { - "$ref": "#/$defs/RunnerInfoV3" + "$ref": "#/$defs/RunnerInfo" }, "schema_version": { "description": "Schema contract version for this JSON payload.", "type": "integer", - "const": 3, + "const": 1, "minimum": 0, "format": "uint32" }, "sources": { "type": "array", "items": { - "$ref": "#/$defs/SourceV3" + "$ref": "#/$defs/SourceEntry" } }, "tasks": { "type": "array", "items": { - "$ref": "#/$defs/DoctorTaskV3" + "$ref": "#/$defs/DoctorTask" } }, "tools": { "type": "array", "items": { - "$ref": "#/$defs/ToolV3" + "$ref": "#/$defs/Tool" } } }, diff --git a/schemas/doctor.v1.example.json b/schemas/doctor.v1.example.json deleted file mode 100644 index 17a39095..00000000 --- a/schemas/doctor.v1.example.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "schema_version": 1, - "root": "/path/to/project", - "ecosystems": [ - "node", - "rust" - ], - "detected": { - "package_managers": [ - "bun", - "cargo" - ], - "task_runners": [ - "just" - ], - "node_version": null, - "current_node": "24.0.0", - "monorepo": true - }, - "overrides": { - "pm": null, - "pm_by_ecosystem": {}, - "runner": null, - "prefer_runners": [], - "fallback": "probe", - "on_mismatch": "warn", - "explain": false, - "no_warnings": false - }, - "signals": { - "node": { - "lockfile_pm": "bun", - "manifest_pm": { - "pm": "bun", - "source": "packageManager", - "version": "1.0.0", - "on_fail": "ignore" - }, - "path_probe": { - "bun": "/usr/local/bin/bun", - "npm": "/opt/volta/bin/npm", - "pnpm": null, - "yarn": "/opt/volta/bin/yarn" - }, - "volta_shims": { - "npm": { - "resolved": "/opt/volta/tools/image/npm/10.0.0/bin/npm" - }, - "yarn": { - "resolved": null - } - } - } - }, - "decisions": { - "node_pm": { - "pm": "bun", - "via": "bun via package.json \"packageManager\"" - } - }, - "tasks": [ - { - "name": "fmt", - "source": "package.json" - }, - { - "name": "fmt:update", - "source": "package.json" - }, - { - "name": "typecheck", - "source": "package.json" - }, - { - "name": "build-packages", - "source": "justfile" - }, - { - "name": "default", - "source": "justfile" - }, - { - "name": "gen-schema", - "source": "justfile", - "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" - }, - { - "name": "ls", - "source": "justfile" - }, - { - "name": "run", - "source": "justfile" - }, - { - "name": "runner", - "source": "justfile" - }, - { - "name": "test-release", - "source": "justfile", - "description": "Build release bin and verify the facade shims spawn the native binary." - }, - { - "name": "b", - "source": "cargo", - "alias_of": "build" - }, - { - "name": "bb", - "source": "cargo", - "alias_of": "build --bin run --bin runner" - }, - { - "name": "bbr", - "source": "cargo", - "alias_of": "build --bin run --bin runner --release" - }, - { - "name": "bin-run", - "source": "cargo", - "alias_of": "run --quiet --bin run" - }, - { - "name": "bin-runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner" - }, - { - "name": "c", - "source": "cargo", - "alias_of": "check" - }, - { - "name": "cl", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features" - }, - { - "name": "comp", - "source": "cargo", - "alias_of": "run --quiet --bin runner -- completions" - }, - { - "name": "d", - "source": "cargo", - "alias_of": "doc" - }, - { - "name": "f", - "source": "cargo", - "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" - }, - { - "name": "format", - "source": "cargo", - "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" - }, - { - "name": "i", - "source": "cargo", - "alias_of": "install --path ." - }, - { - "name": "l", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" - }, - { - "name": "lint", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" - }, - { - "name": "man", - "source": "cargo", - "alias_of": "run --quiet --features man -- man" - }, - { - "name": "meta", - "source": "cargo", - "alias_of": "metadata --format-version 1" - }, - { - "name": "r", - "source": "cargo", - "alias_of": "run" - }, - { - "name": "rbin-run", - "source": "cargo", - "alias_of": "run --quiet --bin run --release" - }, - { - "name": "rbin-runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner --release" - }, - { - "name": "rm", - "source": "cargo", - "alias_of": "remove" - }, - { - "name": "rq", - "source": "cargo", - "alias_of": "run --quiet" - }, - { - "name": "rr", - "source": "cargo", - "alias_of": "run --release" - }, - { - "name": "runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner" - }, - { - "name": "schema", - "source": "cargo", - "alias_of": "run --quiet --features schema -- schema" - }, - { - "name": "t", - "source": "cargo", - "alias_of": "test" - } - ], - "warnings": [] -} diff --git a/schemas/doctor.v1.schema.json b/schemas/doctor.v1.schema.json deleted file mode 100644 index 08c523c0..00000000 --- a/schemas/doctor.v1.schema.json +++ /dev/null @@ -1,471 +0,0 @@ -{ - "$id": "https://kjanat.github.io/runner/schemas/doctor.v1.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "Decisions": { - "description": "Resolver verdict surface. Mirrors the resolver's `Result` so\nconsumers can branch on the variant before reading the inner shape.", - "type": "object", - "required": [ - "node_pm" - ], - "properties": { - "node_pm": { - "$ref": "#/$defs/NodePmDecision", - "description": "Node script-dispatch PM decision, or an error message when the\nresolver bailed." - } - } - }, - "Detected": { - "description": "Detection results — what the file scan found, before any resolver\npolicy was applied.", - "type": "object", - "required": [ - "current_node", - "monorepo", - "node_version", - "package_managers", - "task_runners" - ], - "properties": { - "current_node": { - "description": "`node --version` output, when the binary is on PATH.", - "type": [ - "null", - "string" - ] - }, - "monorepo": { - "description": "Whether the project looks like a monorepo (workspace globs).", - "type": "boolean" - }, - "node_version": { - "description": "`.nvmrc` / `.node-version` / `engines.node` declaration.", - "anyOf": [ - { - "$ref": "#/$defs/NodeVersionInfo" - }, - { - "type": "null" - } - ] - }, - "package_managers": { - "description": "Detected package managers, in detection-priority order.", - "type": "array", - "items": { - "type": "string" - } - }, - "task_runners": { - "description": "Detected task runners.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "ManifestPm": { - "description": "Manifest-level PM declaration plus the field it came from.", - "type": "object", - "required": [ - "on_fail", - "pm", - "source", - "version" - ], - "properties": { - "on_fail": { - "description": "Effective `onFail` policy (`\"ignore\"`, `\"warn\"`, `\"error\"`).", - "type": "string" - }, - "pm": { - "description": "Declared PM label.", - "type": "string" - }, - "source": { - "description": "Either `\"packageManager\"` or `\"devEngines.packageManager\"`.", - "type": "string" - }, - "version": { - "description": "Version constraint as written, if present.", - "type": [ - "null", - "string" - ] - } - } - }, - "NodePmDecision": { - "description": "Either a resolved Node PM or the diagnostic string for the failure\nthat prevented one. Untagged so consumers can probe via \"is the\n`pm` field present?\".", - "anyOf": [ - { - "description": "Successful resolution.", - "type": "object", - "required": [ - "pm", - "via" - ], - "properties": { - "pm": { - "description": "The chosen PM label.", - "type": "string" - }, - "via": { - "description": "Human-readable `via` line — the same string `--explain` prints.", - "type": "string" - } - } - }, - { - "description": "Resolver bailed; carries the rendered error message.", - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "description": "One-line error description from `ResolveError::Display`.", - "type": "string" - } - } - } - ] - }, - "NodeSignals": { - "description": "Node-ecosystem detection signals: lockfile, manifest, PATH probe.", - "type": "object", - "required": [ - "lockfile_pm", - "manifest_pm", - "path_probe" - ], - "properties": { - "lockfile_pm": { - "description": "PM inferred from the highest-priority lockfile, if any.", - "type": [ - "null", - "string" - ] - }, - "manifest_pm": { - "description": "Manifest declaration (legacy `packageManager` or `devEngines`).", - "anyOf": [ - { - "$ref": "#/$defs/ManifestPm" - }, - { - "type": "null" - } - ] - }, - "path_probe": { - "description": "`bun`/`pnpm`/`yarn`/`npm` -> absolute path on `$PATH` (or null).", - "type": "object", - "additionalProperties": { - "type": [ - "null", - "string" - ] - } - }, - "volta_shims": { - "description": "PATH-probe hits identified as Volta shims, keyed like\n[`Self::path_probe`]. Additive field (no schema bump): absent on\nhosts without Volta and on surfaces that skip shim resolution.", - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/VoltaShimInfo" - } - } - } - }, - "NodeVersionInfo": { - "description": "Node version declaration plus the file it came from.", - "type": "object", - "required": [ - "expected", - "source" - ], - "properties": { - "expected": { - "description": "Version string as written (e.g. `\"20.11.0\"`, `\">=18\"`).", - "type": "string" - }, - "source": { - "description": "Source file that declared the version (e.g. `\".nvmrc\"`).", - "type": "string" - } - } - }, - "OverridesView": { - "description": "Materialised override stack — the inputs that fed into resolver\ndecisions.", - "type": "object", - "required": [ - "explain", - "fallback", - "no_warnings", - "on_mismatch", - "pm", - "pm_by_ecosystem", - "prefer_runners", - "runner" - ], - "properties": { - "explain": { - "description": "Whether the explain trace is on.", - "type": "boolean" - }, - "fallback": { - "description": "Active `FallbackPolicy` label.", - "type": "string" - }, - "no_warnings": { - "description": "Whether warnings are suppressed.", - "type": "boolean" - }, - "on_mismatch": { - "description": "Active `MismatchPolicy` label.", - "type": "string" - }, - "pm": { - "description": "Cross-ecosystem PM override from `--pm` / `RUNNER_PM`.", - "anyOf": [ - { - "$ref": "#/$defs/PmOverrideInfo" - }, - { - "type": "null" - } - ] - }, - "pm_by_ecosystem": { - "description": "Per-ecosystem PM overrides from `runner.toml [pm].`.", - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/PmOverrideInfo" - } - }, - "prefer_runners": { - "description": "Ranked preference list from `[task_runner].prefer`.", - "type": "array", - "items": { - "type": "string" - } - }, - "runner": { - "description": "`--runner` / `RUNNER_RUNNER` override.", - "anyOf": [ - { - "$ref": "#/$defs/RunnerOverrideInfo" - }, - { - "type": "null" - } - ] - } - } - }, - "PmOverrideInfo": { - "description": "PM override + provenance.", - "type": "object", - "required": [ - "origin", - "pm" - ], - "properties": { - "origin": { - "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", - "type": "string" - }, - "pm": { - "description": "The chosen PM label.", - "type": "string" - } - } - }, - "RunnerOverrideInfo": { - "description": "Task-runner override + provenance.", - "type": "object", - "required": [ - "origin", - "runner" - ], - "properties": { - "origin": { - "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", - "type": "string" - }, - "runner": { - "description": "The chosen runner label.", - "type": "string" - } - } - }, - "Signals": { - "description": "Per-ecosystem signals — what the resolver had to work with.", - "type": "object", - "required": [ - "node" - ], - "properties": { - "node": { - "$ref": "#/$defs/NodeSignals", - "description": "Node-ecosystem signals. The schema is intentionally\nnode-flat today; other ecosystems get peer fields as their\nresolver paths land." - } - } - }, - "TaskInfo": { - "description": "Task entry projected into the JSON shape.", - "type": "object", - "required": [ - "name", - "source" - ], - "properties": { - "alias_of": { - "description": "When the task is an alias, the target it resolves to.", - "type": [ - "null", - "string" - ] - }, - "description": { - "description": "Human-readable description, if any.", - "type": [ - "null", - "string" - ] - }, - "name": { - "description": "Task name as it appears in the config.", - "type": "string" - }, - "passthrough_to": { - "description": "When the task's body is a thin wrapper for another runner.", - "type": [ - "null", - "string" - ] - }, - "source": { - "$ref": "#/$defs/TaskSourceLabel" - } - } - }, - "TaskSourceLabel": { - "type": "string", - "enum": [ - "package.json", - "Makefile", - "justfile", - "Taskfile", - "turbo.json", - "deno.json", - "cargo", - "go", - "bacon.toml", - "mise.toml", - "pyproject.toml" - ] - }, - "VoltaShimInfo": { - "description": "What `volta which` said about one shimmed tool.", - "type": "object", - "required": [ - "resolved" - ], - "properties": { - "resolved": { - "description": "Real provisioned binary behind the shim; `null` when Volta has\nno version of the tool (\"not provisioned\"). Shims Volta could\nnot classify at all are omitted from the map instead of guessed.", - "type": [ - "null", - "string" - ] - } - } - }, - "WarningInfo": { - "description": "Warning projected into the JSON shape. The `source`/`detail` split\nis kept stable from the pre-A4 flat-struct days so existing\nconsumers (the `doctor` test suite, ad-hoc `jq` queries) keep\nworking.", - "type": "object", - "required": [ - "detail", - "source" - ], - "properties": { - "detail": { - "description": "Human-readable detail.", - "type": "string" - }, - "source": { - "description": "Subsystem the warning came from (e.g. `\"package.json\"`).", - "type": "string" - } - } - } - }, - "title": "runner doctor --json --schema-version 1", - "description": "JSON schema for the legacy v1 `runner doctor --json` document. v1 uses filename-style task source labels.", - "type": "object", - "required": [ - "decisions", - "detected", - "ecosystems", - "overrides", - "root", - "schema_version", - "signals", - "warnings" - ], - "properties": { - "$schema": { - "description": "URI of the JSON Schema that describes this payload.", - "type": "string" - }, - "decisions": { - "$ref": "#/$defs/Decisions", - "description": "Resolver verdict (or first-class error if the chain bailed)." - }, - "detected": { - "$ref": "#/$defs/Detected", - "description": "Raw, type-deduplicated detection results: PMs, runners, Node\nversion, monorepo flag. Stable across resolver behavior tweaks." - }, - "ecosystems": { - "description": "Detected ecosystems, in the order their package managers were\nfound by [`crate::detect`].", - "type": "array", - "items": { - "type": "string" - } - }, - "overrides": { - "$ref": "#/$defs/OverridesView", - "description": "Effective override stack — CLI, env, and config bundled." - }, - "root": { - "description": "Absolute path of the project root the report describes.", - "type": "string" - }, - "schema_version": { - "description": "Schema contract version for this JSON payload.", - "type": "integer", - "const": 1, - "minimum": 0, - "format": "uint32" - }, - "signals": { - "$ref": "#/$defs/Signals", - "description": "Per-ecosystem detection signals: lockfile pick, manifest\ndeclaration, PATH probe results." - }, - "tasks": { - "description": "Full task list. Subcommands that don't care omit this via\nprojection.", - "type": "array", - "items": { - "$ref": "#/$defs/TaskInfo" - } - }, - "warnings": { - "description": "Diagnostic warnings from both detection (`ctx.warnings`) and\nthe resolver (`ResolvedPm.warnings`), flattened.", - "type": "array", - "items": { - "$ref": "#/$defs/WarningInfo" - } - } - } -} diff --git a/schemas/doctor.v2.example.json b/schemas/doctor.v2.example.json deleted file mode 100644 index 952f5816..00000000 --- a/schemas/doctor.v2.example.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "schema_version": 2, - "root": "/path/to/project", - "ecosystems": [ - "node", - "rust" - ], - "detected": { - "package_managers": [ - "bun", - "cargo" - ], - "task_runners": [ - "just" - ], - "node_version": null, - "current_node": "24.0.0", - "monorepo": true - }, - "overrides": { - "pm": null, - "pm_by_ecosystem": {}, - "runner": null, - "prefer_runners": [], - "fallback": "probe", - "on_mismatch": "warn", - "explain": false, - "no_warnings": false - }, - "signals": { - "node": { - "lockfile_pm": "bun", - "manifest_pm": { - "pm": "bun", - "source": "packageManager", - "version": "1.0.0", - "on_fail": "ignore" - }, - "path_probe": { - "bun": "/usr/local/bin/bun", - "npm": "/opt/volta/bin/npm", - "pnpm": null, - "yarn": "/opt/volta/bin/yarn" - }, - "volta_shims": { - "npm": { - "resolved": "/opt/volta/tools/image/npm/10.0.0/bin/npm" - }, - "yarn": { - "resolved": null - } - } - } - }, - "decisions": { - "node_pm": { - "pm": "bun", - "via": "bun via package.json \"packageManager\"" - } - }, - "tasks": [ - { - "name": "fmt", - "source": "package.json" - }, - { - "name": "fmt:update", - "source": "package.json" - }, - { - "name": "typecheck", - "source": "package.json" - }, - { - "name": "build-packages", - "source": "just" - }, - { - "name": "default", - "source": "just" - }, - { - "name": "gen-schema", - "source": "just", - "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" - }, - { - "name": "ls", - "source": "just" - }, - { - "name": "run", - "source": "just" - }, - { - "name": "runner", - "source": "just" - }, - { - "name": "test-release", - "source": "just", - "description": "Build release bin and verify the facade shims spawn the native binary." - }, - { - "name": "b", - "source": "cargo", - "alias_of": "build" - }, - { - "name": "bb", - "source": "cargo", - "alias_of": "build --bin run --bin runner" - }, - { - "name": "bbr", - "source": "cargo", - "alias_of": "build --bin run --bin runner --release" - }, - { - "name": "bin-run", - "source": "cargo", - "alias_of": "run --quiet --bin run" - }, - { - "name": "bin-runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner" - }, - { - "name": "c", - "source": "cargo", - "alias_of": "check" - }, - { - "name": "cl", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features" - }, - { - "name": "comp", - "source": "cargo", - "alias_of": "run --quiet --bin runner -- completions" - }, - { - "name": "d", - "source": "cargo", - "alias_of": "doc" - }, - { - "name": "f", - "source": "cargo", - "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" - }, - { - "name": "format", - "source": "cargo", - "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" - }, - { - "name": "i", - "source": "cargo", - "alias_of": "install --path ." - }, - { - "name": "l", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" - }, - { - "name": "lint", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" - }, - { - "name": "man", - "source": "cargo", - "alias_of": "run --quiet --features man -- man" - }, - { - "name": "meta", - "source": "cargo", - "alias_of": "metadata --format-version 1" - }, - { - "name": "r", - "source": "cargo", - "alias_of": "run" - }, - { - "name": "rbin-run", - "source": "cargo", - "alias_of": "run --quiet --bin run --release" - }, - { - "name": "rbin-runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner --release" - }, - { - "name": "rm", - "source": "cargo", - "alias_of": "remove" - }, - { - "name": "rq", - "source": "cargo", - "alias_of": "run --quiet" - }, - { - "name": "rr", - "source": "cargo", - "alias_of": "run --release" - }, - { - "name": "runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner" - }, - { - "name": "schema", - "source": "cargo", - "alias_of": "run --quiet --features schema -- schema" - }, - { - "name": "t", - "source": "cargo", - "alias_of": "test" - } - ], - "warnings": [] -} diff --git a/schemas/doctor.v2.schema.json b/schemas/doctor.v2.schema.json deleted file mode 100644 index 19b50566..00000000 --- a/schemas/doctor.v2.schema.json +++ /dev/null @@ -1,471 +0,0 @@ -{ - "$id": "https://kjanat.github.io/runner/schemas/doctor.v2.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "Decisions": { - "description": "Resolver verdict surface. Mirrors the resolver's `Result` so\nconsumers can branch on the variant before reading the inner shape.", - "type": "object", - "required": [ - "node_pm" - ], - "properties": { - "node_pm": { - "$ref": "#/$defs/NodePmDecision", - "description": "Node script-dispatch PM decision, or an error message when the\nresolver bailed." - } - } - }, - "Detected": { - "description": "Detection results — what the file scan found, before any resolver\npolicy was applied.", - "type": "object", - "required": [ - "current_node", - "monorepo", - "node_version", - "package_managers", - "task_runners" - ], - "properties": { - "current_node": { - "description": "`node --version` output, when the binary is on PATH.", - "type": [ - "null", - "string" - ] - }, - "monorepo": { - "description": "Whether the project looks like a monorepo (workspace globs).", - "type": "boolean" - }, - "node_version": { - "description": "`.nvmrc` / `.node-version` / `engines.node` declaration.", - "anyOf": [ - { - "$ref": "#/$defs/NodeVersionInfo" - }, - { - "type": "null" - } - ] - }, - "package_managers": { - "description": "Detected package managers, in detection-priority order.", - "type": "array", - "items": { - "type": "string" - } - }, - "task_runners": { - "description": "Detected task runners.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "ManifestPm": { - "description": "Manifest-level PM declaration plus the field it came from.", - "type": "object", - "required": [ - "on_fail", - "pm", - "source", - "version" - ], - "properties": { - "on_fail": { - "description": "Effective `onFail` policy (`\"ignore\"`, `\"warn\"`, `\"error\"`).", - "type": "string" - }, - "pm": { - "description": "Declared PM label.", - "type": "string" - }, - "source": { - "description": "Either `\"packageManager\"` or `\"devEngines.packageManager\"`.", - "type": "string" - }, - "version": { - "description": "Version constraint as written, if present.", - "type": [ - "null", - "string" - ] - } - } - }, - "NodePmDecision": { - "description": "Either a resolved Node PM or the diagnostic string for the failure\nthat prevented one. Untagged so consumers can probe via \"is the\n`pm` field present?\".", - "anyOf": [ - { - "description": "Successful resolution.", - "type": "object", - "required": [ - "pm", - "via" - ], - "properties": { - "pm": { - "description": "The chosen PM label.", - "type": "string" - }, - "via": { - "description": "Human-readable `via` line — the same string `--explain` prints.", - "type": "string" - } - } - }, - { - "description": "Resolver bailed; carries the rendered error message.", - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "description": "One-line error description from `ResolveError::Display`.", - "type": "string" - } - } - } - ] - }, - "NodeSignals": { - "description": "Node-ecosystem detection signals: lockfile, manifest, PATH probe.", - "type": "object", - "required": [ - "lockfile_pm", - "manifest_pm", - "path_probe" - ], - "properties": { - "lockfile_pm": { - "description": "PM inferred from the highest-priority lockfile, if any.", - "type": [ - "null", - "string" - ] - }, - "manifest_pm": { - "description": "Manifest declaration (legacy `packageManager` or `devEngines`).", - "anyOf": [ - { - "$ref": "#/$defs/ManifestPm" - }, - { - "type": "null" - } - ] - }, - "path_probe": { - "description": "`bun`/`pnpm`/`yarn`/`npm` -> absolute path on `$PATH` (or null).", - "type": "object", - "additionalProperties": { - "type": [ - "null", - "string" - ] - } - }, - "volta_shims": { - "description": "PATH-probe hits identified as Volta shims, keyed like\n[`Self::path_probe`]. Additive field (no schema bump): absent on\nhosts without Volta and on surfaces that skip shim resolution.", - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/VoltaShimInfo" - } - } - } - }, - "NodeVersionInfo": { - "description": "Node version declaration plus the file it came from.", - "type": "object", - "required": [ - "expected", - "source" - ], - "properties": { - "expected": { - "description": "Version string as written (e.g. `\"20.11.0\"`, `\">=18\"`).", - "type": "string" - }, - "source": { - "description": "Source file that declared the version (e.g. `\".nvmrc\"`).", - "type": "string" - } - } - }, - "OverridesView": { - "description": "Materialised override stack — the inputs that fed into resolver\ndecisions.", - "type": "object", - "required": [ - "explain", - "fallback", - "no_warnings", - "on_mismatch", - "pm", - "pm_by_ecosystem", - "prefer_runners", - "runner" - ], - "properties": { - "explain": { - "description": "Whether the explain trace is on.", - "type": "boolean" - }, - "fallback": { - "description": "Active `FallbackPolicy` label.", - "type": "string" - }, - "no_warnings": { - "description": "Whether warnings are suppressed.", - "type": "boolean" - }, - "on_mismatch": { - "description": "Active `MismatchPolicy` label.", - "type": "string" - }, - "pm": { - "description": "Cross-ecosystem PM override from `--pm` / `RUNNER_PM`.", - "anyOf": [ - { - "$ref": "#/$defs/PmOverrideInfo" - }, - { - "type": "null" - } - ] - }, - "pm_by_ecosystem": { - "description": "Per-ecosystem PM overrides from `runner.toml [pm].`.", - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/PmOverrideInfo" - } - }, - "prefer_runners": { - "description": "Ranked preference list from `[task_runner].prefer`.", - "type": "array", - "items": { - "type": "string" - } - }, - "runner": { - "description": "`--runner` / `RUNNER_RUNNER` override.", - "anyOf": [ - { - "$ref": "#/$defs/RunnerOverrideInfo" - }, - { - "type": "null" - } - ] - } - } - }, - "PmOverrideInfo": { - "description": "PM override + provenance.", - "type": "object", - "required": [ - "origin", - "pm" - ], - "properties": { - "origin": { - "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", - "type": "string" - }, - "pm": { - "description": "The chosen PM label.", - "type": "string" - } - } - }, - "RunnerOverrideInfo": { - "description": "Task-runner override + provenance.", - "type": "object", - "required": [ - "origin", - "runner" - ], - "properties": { - "origin": { - "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", - "type": "string" - }, - "runner": { - "description": "The chosen runner label.", - "type": "string" - } - } - }, - "Signals": { - "description": "Per-ecosystem signals — what the resolver had to work with.", - "type": "object", - "required": [ - "node" - ], - "properties": { - "node": { - "$ref": "#/$defs/NodeSignals", - "description": "Node-ecosystem signals. The schema is intentionally\nnode-flat today; other ecosystems get peer fields as their\nresolver paths land." - } - } - }, - "TaskInfo": { - "description": "Task entry projected into the JSON shape.", - "type": "object", - "required": [ - "name", - "source" - ], - "properties": { - "alias_of": { - "description": "When the task is an alias, the target it resolves to.", - "type": [ - "null", - "string" - ] - }, - "description": { - "description": "Human-readable description, if any.", - "type": [ - "null", - "string" - ] - }, - "name": { - "description": "Task name as it appears in the config.", - "type": "string" - }, - "passthrough_to": { - "description": "When the task's body is a thin wrapper for another runner.", - "type": [ - "null", - "string" - ] - }, - "source": { - "$ref": "#/$defs/TaskSourceLabel" - } - } - }, - "TaskSourceLabel": { - "type": "string", - "enum": [ - "package.json", - "make", - "just", - "task", - "turbo", - "deno", - "cargo", - "go", - "bacon", - "mise", - "pyproject.toml" - ] - }, - "VoltaShimInfo": { - "description": "What `volta which` said about one shimmed tool.", - "type": "object", - "required": [ - "resolved" - ], - "properties": { - "resolved": { - "description": "Real provisioned binary behind the shim; `null` when Volta has\nno version of the tool (\"not provisioned\"). Shims Volta could\nnot classify at all are omitted from the map instead of guessed.", - "type": [ - "null", - "string" - ] - } - } - }, - "WarningInfo": { - "description": "Warning projected into the JSON shape. The `source`/`detail` split\nis kept stable from the pre-A4 flat-struct days so existing\nconsumers (the `doctor` test suite, ad-hoc `jq` queries) keep\nworking.", - "type": "object", - "required": [ - "detail", - "source" - ], - "properties": { - "detail": { - "description": "Human-readable detail.", - "type": "string" - }, - "source": { - "description": "Subsystem the warning came from (e.g. `\"package.json\"`).", - "type": "string" - } - } - } - }, - "title": "runner doctor --json --schema-version 2", - "description": "JSON schema for the v2 `runner doctor --json` document. v2 uses tool-name task source labels.", - "type": "object", - "required": [ - "decisions", - "detected", - "ecosystems", - "overrides", - "root", - "schema_version", - "signals", - "warnings" - ], - "properties": { - "$schema": { - "description": "URI of the JSON Schema that describes this payload.", - "type": "string" - }, - "decisions": { - "$ref": "#/$defs/Decisions", - "description": "Resolver verdict (or first-class error if the chain bailed)." - }, - "detected": { - "$ref": "#/$defs/Detected", - "description": "Raw, type-deduplicated detection results: PMs, runners, Node\nversion, monorepo flag. Stable across resolver behavior tweaks." - }, - "ecosystems": { - "description": "Detected ecosystems, in the order their package managers were\nfound by [`crate::detect`].", - "type": "array", - "items": { - "type": "string" - } - }, - "overrides": { - "$ref": "#/$defs/OverridesView", - "description": "Effective override stack — CLI, env, and config bundled." - }, - "root": { - "description": "Absolute path of the project root the report describes.", - "type": "string" - }, - "schema_version": { - "description": "Schema contract version for this JSON payload.", - "type": "integer", - "const": 2, - "minimum": 0, - "format": "uint32" - }, - "signals": { - "$ref": "#/$defs/Signals", - "description": "Per-ecosystem detection signals: lockfile pick, manifest\ndeclaration, PATH probe results." - }, - "tasks": { - "description": "Full task list. Subcommands that don't care omit this via\nprojection.", - "type": "array", - "items": { - "$ref": "#/$defs/TaskInfo" - } - }, - "warnings": { - "description": "Diagnostic warnings from both detection (`ctx.warnings`) and\nthe resolver (`ResolvedPm.warnings`), flattened.", - "type": "array", - "items": { - "$ref": "#/$defs/WarningInfo" - } - } - } -} diff --git a/schemas/list.v2.example.json b/schemas/list.example.json similarity index 99% rename from schemas/list.v2.example.json rename to schemas/list.example.json index 8d37b562..cafe29d9 100644 --- a/schemas/list.v2.example.json +++ b/schemas/list.example.json @@ -1,5 +1,5 @@ { - "schema_version": 2, + "schema_version": 1, "root": "/path/to/project", "tasks": [ { diff --git a/schemas/list.v2.schema.json b/schemas/list.schema.json similarity index 90% rename from schemas/list.v2.schema.json rename to schemas/list.schema.json index 68b6c820..58257258 100644 --- a/schemas/list.v2.schema.json +++ b/schemas/list.schema.json @@ -1,5 +1,5 @@ { - "$id": "https://kjanat.github.io/runner/schemas/list.v2.schema.json", + "$id": "https://kjanat.github.io/runner/schemas/list.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "TaskInfo": { @@ -57,8 +57,8 @@ ] } }, - "title": "runner list --json --schema-version 2", - "description": "JSON schema for `runner list --json --schema-version 2`.", + "title": "runner list --json", + "description": "JSON schema for `runner list --json`.", "type": "object", "required": [ "root", @@ -77,7 +77,7 @@ "schema_version": { "description": "Schema contract version for this JSON payload.", "type": "integer", - "const": 2, + "const": 1, "minimum": 0, "format": "uint32" }, diff --git a/schemas/list.v1.example.json b/schemas/list.v1.example.json deleted file mode 100644 index f56c1cf1..00000000 --- a/schemas/list.v1.example.json +++ /dev/null @@ -1,173 +0,0 @@ -{ - "schema_version": 1, - "root": "/path/to/project", - "tasks": [ - { - "name": "fmt", - "source": "package.json" - }, - { - "name": "fmt:update", - "source": "package.json" - }, - { - "name": "typecheck", - "source": "package.json" - }, - { - "name": "build-packages", - "source": "justfile" - }, - { - "name": "default", - "source": "justfile" - }, - { - "name": "gen-schema", - "source": "justfile", - "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" - }, - { - "name": "ls", - "source": "justfile" - }, - { - "name": "run", - "source": "justfile" - }, - { - "name": "runner", - "source": "justfile" - }, - { - "name": "test-release", - "source": "justfile", - "description": "Build release bin and verify the facade shims spawn the native binary." - }, - { - "name": "b", - "source": "cargo", - "alias_of": "build" - }, - { - "name": "bb", - "source": "cargo", - "alias_of": "build --bin run --bin runner" - }, - { - "name": "bbr", - "source": "cargo", - "alias_of": "build --bin run --bin runner --release" - }, - { - "name": "bin-run", - "source": "cargo", - "alias_of": "run --quiet --bin run" - }, - { - "name": "bin-runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner" - }, - { - "name": "c", - "source": "cargo", - "alias_of": "check" - }, - { - "name": "cl", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features" - }, - { - "name": "comp", - "source": "cargo", - "alias_of": "run --quiet --bin runner -- completions" - }, - { - "name": "d", - "source": "cargo", - "alias_of": "doc" - }, - { - "name": "f", - "source": "cargo", - "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" - }, - { - "name": "format", - "source": "cargo", - "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" - }, - { - "name": "i", - "source": "cargo", - "alias_of": "install --path ." - }, - { - "name": "l", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" - }, - { - "name": "lint", - "source": "cargo", - "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" - }, - { - "name": "man", - "source": "cargo", - "alias_of": "run --quiet --features man -- man" - }, - { - "name": "meta", - "source": "cargo", - "alias_of": "metadata --format-version 1" - }, - { - "name": "r", - "source": "cargo", - "alias_of": "run" - }, - { - "name": "rbin-run", - "source": "cargo", - "alias_of": "run --quiet --bin run --release" - }, - { - "name": "rbin-runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner --release" - }, - { - "name": "rm", - "source": "cargo", - "alias_of": "remove" - }, - { - "name": "rq", - "source": "cargo", - "alias_of": "run --quiet" - }, - { - "name": "rr", - "source": "cargo", - "alias_of": "run --release" - }, - { - "name": "runner", - "source": "cargo", - "alias_of": "run --quiet --bin runner" - }, - { - "name": "schema", - "source": "cargo", - "alias_of": "run --quiet --features schema -- schema" - }, - { - "name": "t", - "source": "cargo", - "alias_of": "test" - } - ] -} diff --git a/schemas/list.v1.schema.json b/schemas/list.v1.schema.json deleted file mode 100644 index 3be6fa19..00000000 --- a/schemas/list.v1.schema.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "$id": "https://kjanat.github.io/runner/schemas/list.v1.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "TaskInfo": { - "description": "Task entry projected into the JSON shape.", - "type": "object", - "required": [ - "name", - "source" - ], - "properties": { - "alias_of": { - "description": "When the task is an alias, the target it resolves to.", - "type": [ - "null", - "string" - ] - }, - "description": { - "description": "Human-readable description, if any.", - "type": [ - "null", - "string" - ] - }, - "name": { - "description": "Task name as it appears in the config.", - "type": "string" - }, - "passthrough_to": { - "description": "When the task's body is a thin wrapper for another runner.", - "type": [ - "null", - "string" - ] - }, - "source": { - "$ref": "#/$defs/TaskSourceLabel" - } - } - }, - "TaskSourceLabel": { - "type": "string", - "enum": [ - "package.json", - "Makefile", - "justfile", - "Taskfile", - "turbo.json", - "deno.json", - "cargo", - "go", - "bacon.toml", - "mise.toml", - "pyproject.toml" - ] - } - }, - "title": "runner list --json --schema-version 1", - "description": "JSON schema for `runner list --json --schema-version 1`.", - "type": "object", - "required": [ - "root", - "schema_version", - "tasks" - ], - "properties": { - "$schema": { - "description": "URI of the JSON Schema that describes this payload.", - "type": "string" - }, - "root": { - "description": "Project root.", - "type": "string" - }, - "schema_version": { - "description": "Schema contract version for this JSON payload.", - "type": "integer", - "const": 1, - "minimum": 0, - "format": "uint32" - }, - "tasks": { - "description": "Tasks, optionally filtered by source.", - "type": "array", - "items": { - "$ref": "#/$defs/TaskInfo" - } - } - } -} diff --git a/schemas/why.v3.example.json b/schemas/why.example.json similarity index 98% rename from schemas/why.v3.example.json rename to schemas/why.example.json index fc00159b..c8f2c775 100644 --- a/schemas/why.v3.example.json +++ b/schemas/why.example.json @@ -1,5 +1,5 @@ { - "schema_version": 3, + "schema_version": 1, "kind": "runner.why", "root": "/path/to/project", "query": "t", diff --git a/schemas/why.v3.schema.json b/schemas/why.schema.json similarity index 93% rename from schemas/why.v3.schema.json rename to schemas/why.schema.json index 25373f4d..63e53310 100644 --- a/schemas/why.v3.schema.json +++ b/schemas/why.schema.json @@ -1,5 +1,5 @@ { - "$id": "https://kjanat.github.io/runner/schemas/why.v3.schema.json", + "$id": "https://kjanat.github.io/runner/schemas/why.schema.json", "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "PmResolution": { @@ -71,7 +71,7 @@ "pyproject.toml" ] }, - "WhyCandidateV3": { + "WhyCandidate": { "description": "One candidate: the task's identity plus how it matched the query.", "type": "object", "required": [ @@ -80,15 +80,15 @@ ], "properties": { "match": { - "$ref": "#/$defs/WhyMatchV3" + "$ref": "#/$defs/WhyMatch" }, "task": { - "$ref": "#/$defs/WhyTaskV3" + "$ref": "#/$defs/WhyTask" } }, "additionalProperties": false }, - "WhyDecisionV3": { + "WhyDecision": { "type": "object", "required": [ "reason", @@ -105,7 +105,7 @@ }, "additionalProperties": false }, - "WhyMatchV3": { + "WhyMatch": { "type": "object", "required": [ "depth", @@ -156,7 +156,7 @@ }, "additionalProperties": false }, - "WhyTaskV3": { + "WhyTask": { "type": "object", "required": [ "aliases", @@ -256,8 +256,8 @@ } } }, - "title": "runner why --json --schema-version 3", - "description": "JSON schema for `runner why --json --schema-version 3`.", + "title": "runner why --json", + "description": "JSON schema for `runner why --json`: candidate `{task, match}` pairs plus the selection decision.", "type": "object", "required": [ "candidates", @@ -277,11 +277,11 @@ "candidates": { "type": "array", "items": { - "$ref": "#/$defs/WhyCandidateV3" + "$ref": "#/$defs/WhyCandidate" } }, "decision": { - "$ref": "#/$defs/WhyDecisionV3" + "$ref": "#/$defs/WhyDecision" }, "kind": { "description": "Payload discriminator; always \"runner.why\".", @@ -308,14 +308,14 @@ "schema_version": { "description": "Schema contract version for this JSON payload.", "type": "integer", - "const": 3, + "const": 1, "minimum": 0, "format": "uint32" }, "selected": { "anyOf": [ { - "$ref": "#/$defs/WhyCandidateV3" + "$ref": "#/$defs/WhyCandidate" }, { "type": "null" diff --git a/schemas/why.v1.example.json b/schemas/why.v1.example.json deleted file mode 100644 index 182b1afc..00000000 --- a/schemas/why.v1.example.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "schema_version": 1, - "task": "t", - "candidates": [ - { - "source": "cargo", - "source_priority": 2, - "depth": 0, - "display_order": 6, - "is_alias": true, - "alias_of": "test", - "description": null, - "passthrough_to": null, - "source_dir": "/path/to/project/.cargo/config.toml" - } - ], - "selected": { - "source": "cargo", - "source_priority": 2, - "depth": 0, - "display_order": 6, - "is_alias": true, - "alias_of": "test", - "description": null, - "passthrough_to": null, - "source_dir": "/path/to/project/.cargo/config.toml" - }, - "pm_resolution": null -} diff --git a/schemas/why.v1.schema.json b/schemas/why.v1.schema.json deleted file mode 100644 index 2ba4b688..00000000 --- a/schemas/why.v1.schema.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "$id": "https://kjanat.github.io/runner/schemas/why.v1.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "PmResolution": { - "anyOf": [ - { - "type": "object", - "required": [ - "pm", - "via", - "warnings" - ], - "properties": { - "pm": { - "type": "string" - }, - "via": { - "type": "string" - }, - "warnings": { - "type": "array", - "items": { - "$ref": "#/$defs/WhyWarning" - } - } - } - }, - { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } - } - ] - }, - "TaskSourceLabel": { - "type": "string", - "enum": [ - "package.json", - "Makefile", - "justfile", - "Taskfile", - "turbo.json", - "deno.json", - "cargo", - "go", - "bacon.toml", - "mise.toml", - "pyproject.toml" - ] - }, - "WhyCandidate": { - "type": "object", - "required": [ - "alias_of", - "depth", - "description", - "display_order", - "is_alias", - "passthrough_to", - "source", - "source_dir", - "source_priority" - ], - "properties": { - "alias_of": { - "type": [ - "null", - "string" - ] - }, - "depth": { - "type": [ - "integer", - "null" - ], - "minimum": 0, - "format": "uint" - }, - "description": { - "type": [ - "null", - "string" - ] - }, - "display_order": { - "type": "integer", - "minimum": 0, - "maximum": 255, - "format": "uint8" - }, - "is_alias": { - "type": "boolean" - }, - "passthrough_to": { - "type": [ - "null", - "string" - ] - }, - "source": { - "$ref": "#/$defs/TaskSourceLabel" - }, - "source_dir": { - "type": [ - "null", - "string" - ] - }, - "source_priority": { - "type": "integer", - "minimum": 0, - "maximum": 65535, - "format": "uint16" - } - } - }, - "WhyWarning": { - "type": "object", - "required": [ - "detail", - "source" - ], - "properties": { - "detail": { - "type": "string" - }, - "source": { - "type": "string" - } - } - } - }, - "title": "runner why --json --schema-version 1", - "description": "JSON schema for `runner why --json --schema-version 1`.", - "type": "object", - "required": [ - "candidates", - "pm_resolution", - "schema_version", - "selected", - "task" - ], - "properties": { - "$schema": { - "description": "URI of the JSON Schema that describes this payload.", - "type": "string" - }, - "candidates": { - "type": "array", - "items": { - "$ref": "#/$defs/WhyCandidate" - } - }, - "pm_resolution": { - "anyOf": [ - { - "$ref": "#/$defs/PmResolution" - }, - { - "type": "null" - } - ] - }, - "schema_version": { - "description": "Schema contract version for this JSON payload.", - "type": "integer", - "const": 1, - "minimum": 0, - "format": "uint32" - }, - "selected": { - "anyOf": [ - { - "$ref": "#/$defs/WhyCandidate" - }, - { - "type": "null" - } - ] - }, - "task": { - "type": "string" - } - } -} diff --git a/schemas/why.v2.example.json b/schemas/why.v2.example.json deleted file mode 100644 index fac81d0d..00000000 --- a/schemas/why.v2.example.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "schema_version": 2, - "task": "t", - "candidates": [ - { - "source": "cargo", - "source_priority": 2, - "depth": 0, - "display_order": 6, - "is_alias": true, - "alias_of": "test", - "description": null, - "passthrough_to": null, - "source_dir": "/path/to/project/.cargo/config.toml" - } - ], - "selected": { - "source": "cargo", - "source_priority": 2, - "depth": 0, - "display_order": 6, - "is_alias": true, - "alias_of": "test", - "description": null, - "passthrough_to": null, - "source_dir": "/path/to/project/.cargo/config.toml" - }, - "pm_resolution": null -} diff --git a/schemas/why.v2.schema.json b/schemas/why.v2.schema.json deleted file mode 100644 index 7c25a8e2..00000000 --- a/schemas/why.v2.schema.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "$id": "https://kjanat.github.io/runner/schemas/why.v2.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "PmResolution": { - "anyOf": [ - { - "type": "object", - "required": [ - "pm", - "via", - "warnings" - ], - "properties": { - "pm": { - "type": "string" - }, - "via": { - "type": "string" - }, - "warnings": { - "type": "array", - "items": { - "$ref": "#/$defs/WhyWarning" - } - } - } - }, - { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - } - } - } - ] - }, - "TaskSourceLabel": { - "type": "string", - "enum": [ - "package.json", - "make", - "just", - "task", - "turbo", - "deno", - "cargo", - "go", - "bacon", - "mise", - "pyproject.toml" - ] - }, - "WhyCandidate": { - "type": "object", - "required": [ - "alias_of", - "depth", - "description", - "display_order", - "is_alias", - "passthrough_to", - "source", - "source_dir", - "source_priority" - ], - "properties": { - "alias_of": { - "type": [ - "null", - "string" - ] - }, - "depth": { - "type": [ - "integer", - "null" - ], - "minimum": 0, - "format": "uint" - }, - "description": { - "type": [ - "null", - "string" - ] - }, - "display_order": { - "type": "integer", - "minimum": 0, - "maximum": 255, - "format": "uint8" - }, - "is_alias": { - "type": "boolean" - }, - "passthrough_to": { - "type": [ - "null", - "string" - ] - }, - "source": { - "$ref": "#/$defs/TaskSourceLabel" - }, - "source_dir": { - "type": [ - "null", - "string" - ] - }, - "source_priority": { - "type": "integer", - "minimum": 0, - "maximum": 65535, - "format": "uint16" - } - } - }, - "WhyWarning": { - "type": "object", - "required": [ - "detail", - "source" - ], - "properties": { - "detail": { - "type": "string" - }, - "source": { - "type": "string" - } - } - } - }, - "title": "runner why --json --schema-version 2", - "description": "JSON schema for `runner why --json --schema-version 2`.", - "type": "object", - "required": [ - "candidates", - "pm_resolution", - "schema_version", - "selected", - "task" - ], - "properties": { - "$schema": { - "description": "URI of the JSON Schema that describes this payload.", - "type": "string" - }, - "candidates": { - "type": "array", - "items": { - "$ref": "#/$defs/WhyCandidate" - } - }, - "pm_resolution": { - "anyOf": [ - { - "$ref": "#/$defs/PmResolution" - }, - { - "type": "null" - } - ] - }, - "schema_version": { - "description": "Schema contract version for this JSON payload.", - "type": "integer", - "const": 2, - "minimum": 0, - "format": "uint32" - }, - "selected": { - "anyOf": [ - { - "$ref": "#/$defs/WhyCandidate" - }, - { - "type": "null" - } - ] - }, - "task": { - "type": "string" - } - } -} diff --git a/src/cli.rs b/src/cli.rs index 2938ffc2..3b5a96b1 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1259,26 +1259,16 @@ pub(crate) struct GlobalOpts { )] pub quiet: bool, - /// Pin the JSON output schema to a specific version. Defaults to the - /// 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"`). 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. + /// Pin the `--json` output schema version. Currently always `1` — + /// kept for scripts that already pass it explicitly; any other value + /// is rejected. #[arg( long = "schema-version", global = true, - value_parser = clap::value_parser!(u32).range(1..=3), + value_parser = clap::value_parser!(u32).range(1..=1), value_name = "N", display_order = help_order::SCHEMA_VERSION, - help = concat!( - "Pin ", cyan!("--json"), " schema (doctor/why ", - cyan!("1"), "-", cyan!("3"), ", list ", - cyan!("1"), "-", cyan!("2"), "; default latest)" - ), + help = concat!("Pin ", cyan!("--json"), " schema (currently always ", cyan!("1"), ")"), )] pub schema_version: Option, } diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index 4bdf130b..caef707f 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -1,15 +1,13 @@ //! `runner doctor` — dump every signal the resolver considers. //! -//! Surface for users (and bug reports) to inspect what runner sees in the -//! current project: detected package managers and task runners, the -//! manifest declaration if any, lockfile presence, override sources in -//! effect, and the resolved decision. Pairs with `--explain` (one-line -//! trace at run time) and `runner why ` (per-task source pick). +//! Surface for users (and bug reports) to inspect what runner sees in the current project: +//! detected package managers and task runners, the manifest declaration if any, lockfile presence, +//! override sources in effect, and the resolved decision. Pairs with `--explain` (one-line trace at run time) +//! and `runner why ` (per-task source pick). //! //! Two output formats: -//! - human (default): colored, grouped, easy to skim. -//! - `--json`: machine-readable schema-versioned JSON for piping into -//! `jq`, scripts, or bug-report templates. +//! - human (default): colored, grouped, easy to skim. Reads the flat [`Project`] shape internally (same one `list`/`info` serve). +//! - `--json`: the structured [`crate::schema::doctor::DoctorReport`], machine-readable JSON for piping into `jq`, scripts, or bug-report templates. use std::fmt::Write as _; @@ -19,55 +17,34 @@ use serde_json::{Map, Value}; use crate::resolver::ResolutionOverrides; use crate::schema::Project; +use crate::schema::doctor::DoctorReport; 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 /// -/// Propagates `Resolver::resolve_node_pm` errors when the configured -/// fallback policy is `error` and nothing is on `$PATH`. Always succeeds -/// for the `probe`/`npm` fallback policies on real systems. +/// Propagates `Resolver::resolve_node_pm` errors when the configured fallback policy is `error` and +/// nothing is on `$PATH`. Always succeeds for the `probe`/`npm` fallback policies on real systems. pub(crate) fn doctor( ctx: &ProjectContext, overrides: &ResolutionOverrides, json: bool, - schema_version: u32, ) -> Result<()> { - 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); + if json { + let report = DoctorReport::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)?); - } else { - // The human renderer was written against a `serde_json::Value` - // so it can address fields by name without a forest of - // `match`es. Serializing the typed report once and traversing - // the resulting `Value` keeps that ergonomics while the JSON - // contract itself stays typed via `Project`. - let report = serde_json::to_value(&project)?; - print_human(&report, overrides); - } + let project = Project::build_with_schema(ctx, overrides, true); + // The human renderer was written against a `serde_json::Value` so it + // can address fields by name without a forest of `match`es. + // Serializing the typed report once and traversing the resulting + // `Value` keeps that ergonomics while the JSON contract itself stays + // typed via `Project`. + let report = serde_json::to_value(&project)?; + print_human(&report, overrides); Ok(()) } @@ -329,7 +306,7 @@ mod tests { let report = build_report(&ctx, &ResolutionOverrides::default()); // `Project::build` passes `resolve_shims = false`; the additive - // field must vanish entirely, keeping the v1/v2 shape untouched. + // field must vanish entirely, keeping the flat shape untouched. assert!( report["signals"]["node"].get("volta_shims").is_none(), "volta_shims must be omitted when empty: {}", @@ -346,7 +323,7 @@ mod tests { let ctx = context(); let report = build_report(&ctx, &ResolutionOverrides::default()); - assert_eq!(report["schema_version"], 2); + assert_eq!(report["schema_version"], 1); } #[test] @@ -378,20 +355,8 @@ mod tests { let ctx = context(); // Ensure both rendering paths are exercised; output goes to stdout // which is fine in tests (captured by `cargo test`). - doctor( - &ctx, - &ResolutionOverrides::default(), - true, - crate::schema::CURRENT_VERSION, - ) - .expect("json render should succeed"); - doctor( - &ctx, - &ResolutionOverrides::default(), - false, - crate::schema::CURRENT_VERSION, - ) - .expect("human render should succeed"); + doctor(&ctx, &ResolutionOverrides::default(), true).expect("json render should succeed"); + doctor(&ctx, &ResolutionOverrides::default(), false).expect("human render should succeed"); } #[test] diff --git a/src/cmd/info.rs b/src/cmd/info.rs index 35c03109..1635f5b8 100644 --- a/src/cmd/info.rs +++ b/src/cmd/info.rs @@ -26,17 +26,12 @@ pub(crate) fn info( ctx: &ProjectContext, overrides: &ResolutionOverrides, json: bool, - schema_version: u32, ) -> Result<()> { if json { - let view = - Project::build_with_schema(ctx, overrides, schema_version, true).into_info_view(); + let view = Project::build_with_schema(ctx, overrides, true).into_info_view(); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(()); } - // Human output is not schema-versioned; callers pass the current version - // only to keep the JSON-capable function signature narrow. - let _ = schema_version; super::print_warnings(ctx, overrides, None); diff --git a/src/cmd/list.rs b/src/cmd/list.rs index 2936a0e0..2b0bfa9e 100644 --- a/src/cmd/list.rs +++ b/src/cmd/list.rs @@ -32,7 +32,6 @@ pub(crate) fn list( raw: bool, json: bool, source: Option<&str>, - schema_version: u32, ) -> Result<()> { let parsed_source = match source { None => None, @@ -46,12 +45,10 @@ pub(crate) fn list( }; if json { - let view = Project::build_with_schema(ctx, overrides, schema_version, false) - .into_list_view(parsed_source); + let view = Project::build_with_schema(ctx, overrides, false).into_list_view(parsed_source); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(()); } - let _ = schema_version; super::print_warnings(ctx, overrides, None); @@ -651,7 +648,6 @@ mod tests { source_path, }; use crate::resolver::ResolutionOverrides; - use crate::schema::CURRENT_VERSION; use crate::tool::test_support::TempDir; use crate::types::{ProjectContext, Task, TaskSource}; @@ -711,7 +707,6 @@ mod tests { false, false, Some("wat"), - CURRENT_VERSION, ) .expect_err("invalid source should error"); diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index 426f2109..346bf721 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -7,7 +7,7 @@ use anyhow::{Context as _, Result, bail}; use schemars::{JsonSchema, Schema}; use serde_json::{Map, Value, json}; -use crate::schema::{Project, project::TaskListView}; +use crate::schema::project::TaskListView; const SCHEMA_DIR: &str = "schemas"; @@ -95,52 +95,28 @@ fn schema_documents() -> Result> { value: config_schema()?, }, SchemaDocument { - filename: "doctor.v1.schema.json", - value: output_schema::>("doctor", 1)?, + filename: "doctor.schema.json", + value: output_schema::>("doctor")?, }, SchemaDocument { - filename: "doctor.v2.schema.json", - value: output_schema::>("doctor", 2)?, + filename: "list.schema.json", + value: output_schema::>("list")?, }, SchemaDocument { - filename: "doctor.v3.schema.json", - value: output_schema::>("doctor", 3)?, - }, - SchemaDocument { - filename: "list.v1.schema.json", - value: output_schema::>("list", 1)?, - }, - SchemaDocument { - filename: "list.v2.schema.json", - value: output_schema::>("list", 2)?, - }, - SchemaDocument { - filename: "why.v1.schema.json", - value: output_schema::>("why", 1)?, - }, - SchemaDocument { - filename: "why.v2.schema.json", - value: output_schema::>("why", 2)?, - }, - SchemaDocument { - filename: "why.v3.schema.json", - value: output_schema::>("why", 3)?, + filename: "why.schema.json", + value: output_schema::>("why")?, }, ]) } -fn output_schema(command: &'static str, version: u32) -> Result { +fn output_schema(command: &'static str) -> Result { let mut schema = serialize_schema_value::()?; - set_object_field(&mut schema, "$id", json!(schema_id(command, version))); - set_object_field(&mut schema, "title", json!(title(command, version))); - set_object_field( - &mut schema, - "description", - json!(description(command, version)), - ); - patch_schema_version_const(&mut schema, version); - patch_source_schema(&mut schema, version); - patch_schema_compat(&mut schema, command, version); + set_object_field(&mut schema, "$id", json!(schema_id(command))); + set_object_field(&mut schema, "title", json!(title(command))); + set_object_field(&mut schema, "description", json!(description(command))); + patch_schema_version_const(&mut schema); + patch_source_schema(&mut schema, command); + patch_schema_compat(&mut schema, command); Ok(schema) } @@ -174,7 +150,7 @@ fn set_object_field(schema: &mut Value, key: &'static str, value: Value) { } } -fn patch_schema_version_const(schema: &mut Value, version: u32) { +fn patch_schema_version_const(schema: &mut Value) { let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else { return; }; @@ -184,29 +160,29 @@ fn patch_schema_version_const(schema: &mut Value, version: u32) { else { return; }; - version_schema.insert("const".to_string(), json!(version)); + version_schema.insert("const".to_string(), json!(crate::schema::SCHEMA_VERSION)); } -fn patch_source_schema(schema: &mut Value, version: u32) { +fn patch_source_schema(schema: &mut Value, command: &str) { let Some(defs) = schema.get_mut("$defs").and_then(Value::as_object_mut) else { return; }; defs.insert( "TaskSourceLabel".to_string(), - task_source_label_schema(version), + task_source_label_schema(command), ); patch_task_info_source(defs); - patch_why_candidate_source(defs); - patch_why_task_v3(defs); - patch_def_field(defs, "SourceV3", "kind", "TaskSourceLabel"); + patch_why_task(defs); + patch_def_field(defs, "SourceEntry", "kind", "TaskSourceLabel"); } -fn patch_schema_compat(schema: &mut Value, command: &str, version: u32) { - if command == "doctor" && version == 3 { - // v3 existed before `quiet`; keep additive fields optional so the - // current public schema still validates older v3 payloads. - remove_required_def_field(schema, "OverridesV3", "quiet"); +fn patch_schema_compat(schema: &mut Value, command: &str) { + if command == "doctor" { + // The structured doctor report existed before `quiet`; keep + // additive fields optional so the committed schema still + // validates payloads emitted before that field landed. + remove_required_def_field(schema, "Overrides", "quiet"); } } @@ -227,23 +203,19 @@ fn patch_task_info_source(defs: &mut Map) { patch_def_field(defs, "TaskInfo", "source", "TaskSourceLabel"); } -fn patch_why_candidate_source(defs: &mut Map) { - patch_def_field(defs, "WhyCandidate", "source", "TaskSourceLabel"); -} - -/// The v3 `why` task object splits the old `source` label into `kind` +/// The `why` task object splits the old flat `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") { +fn patch_why_task(defs: &mut Map) { + if !defs.contains_key("WhyTask") { 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"); + patch_def_field(defs, "WhyTask", "kind", "TaskSourceLabel"); + patch_def_field(defs, "WhyTask", "provider", "ProviderLabel"); } fn patch_def_field( @@ -263,53 +235,59 @@ fn patch_def_field( *field_schema = json!({ "$ref": format!("#/$defs/{target_def}") }); } -fn task_source_label_schema(version: u32) -> Value { - json!({ "type": "string", "enum": source_labels(version) }) +fn task_source_label_schema(command: &str) -> Value { + json!({ "type": "string", "enum": source_labels(command) }) } -/// Closed set for the v3 `provider` field — the tool family that +/// Closed set for the `why` `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", ]; -/// Closed label set for schema version `version`, derived from -/// [`crate::types::TaskSource::all`] through the same -/// [`crate::schema::labels::source_label_for`] dispatcher `why`/`doctor` -/// use at runtime — so the committed schema's enum can't drift from what -/// the binary actually emits. -fn source_labels(version: u32) -> Vec<&'static str> { - crate::types::TaskSource::all() +/// Closed label set for `command`'s source labels, derived from +/// [`crate::types::TaskSource::all`] through the same label functions +/// `list`/`doctor`/`why` use at runtime — so the committed schema's enum +/// can't drift from what the binary actually emits. `list` uses the flat +/// label convention; `doctor`/`why` use the structured one (only +/// `CargoAliases` differs — `"cargo-alias"` vs `"cargo"`). +fn source_labels(command: &str) -> Vec<&'static str> { + use crate::schema::labels::{flat_source_label, structured_source_label}; + use crate::types::TaskSource; + + TaskSource::all() .iter() - .map(|&source| crate::schema::labels::source_label_for(source, version)) + .map(|&source| { + if command == "list" { + flat_source_label(source) + } else { + structured_source_label(source) + } + }) .collect() } -fn schema_id(command: &str, version: u32) -> String { - crate::schema::schema_url(command, version) +fn schema_id(command: &str) -> String { + crate::schema::schema_url(command) } -fn title(command: &str, version: u32) -> String { +fn title(command: &str) -> String { match command { - "why" => format!("runner why --json --schema-version {version}"), - _ => format!("runner {command} --json --schema-version {version}"), + "why" => "runner why --json".to_string(), + _ => format!("runner {command} --json"), } } -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", 2) => "JSON schema for the v2 `runner doctor --json` document. v2 uses \ - tool-name task source labels." +fn description(command: &str) -> String { + match command { + "doctor" => "JSON schema for `runner doctor --json`: structured diagnostic inventory with \ + invocation/environment provenance, per-ecosystem decisions, sources, \ + fqn-keyed tasks, tools, conflicts, and diagnostics." .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." + "why" => "JSON schema for `runner why --json`: candidate `{task, match}` pairs \ + plus the selection decision." .to_string(), - _ => format!("JSON schema for `{}`.", title(command, version)), + _ => format!("JSON schema for `{}`.", title(command)), } } @@ -319,23 +297,23 @@ mod tests { use super::output_schema; - fn overrides_v3(schema: &Value) -> &Value { + fn overrides_def(schema: &Value) -> &Value { schema .get("$defs") .and_then(Value::as_object) - .and_then(|defs| defs.get("OverridesV3")) - .expect("schema should define OverridesV3") + .and_then(|defs| defs.get("Overrides")) + .expect("schema should define Overrides") } fn quiet_is_optional(schema: &Value) -> bool { - overrides_v3(schema) + overrides_def(schema) .get("required") .and_then(Value::as_array) .is_some_and(|required| !required.iter().any(|name| name.as_str() == Some("quiet"))) } fn quiet_type(schema: &Value) -> Option<&str> { - overrides_v3(schema) + overrides_def(schema) .get("properties") .and_then(Value::as_object) .and_then(|properties| properties.get("quiet")) @@ -344,19 +322,18 @@ mod tests { } #[test] - fn doctor_v3_schema_keeps_quiet_optional_for_compat() { - let schema = - output_schema::>("doctor", 3) - .expect("doctor v3 schema should render"); + fn doctor_schema_keeps_quiet_optional_for_compat() { + let schema = output_schema::>("doctor") + .expect("doctor schema should render"); assert!(quiet_is_optional(&schema)); assert_eq!(quiet_type(&schema), Some("boolean")); } #[test] - fn committed_doctor_v3_schema_keeps_quiet_optional_for_compat() { - let raw = std::fs::read_to_string("schemas/doctor.v3.schema.json") - .expect("committed doctor v3 schema should be readable"); + fn committed_doctor_schema_keeps_quiet_optional_for_compat() { + let raw = std::fs::read_to_string("schemas/doctor.schema.json") + .expect("committed doctor schema should be readable"); let schema: Value = serde_json::from_str(&raw).expect("schema should parse as JSON"); assert!(quiet_is_optional(&schema)); @@ -364,56 +341,58 @@ mod tests { } #[test] - fn committed_doctor_v3_example_includes_quiet_override() { - let raw = std::fs::read_to_string("schemas/doctor.v3.example.json") - .expect("committed doctor v3 example should be readable"); + fn committed_doctor_example_includes_quiet_override() { + let raw = std::fs::read_to_string("schemas/doctor.example.json") + .expect("committed doctor example should be readable"); let example: Value = serde_json::from_str(&raw).expect("example should parse as JSON"); assert_eq!(example["overrides"]["quiet"], serde_json::json!(false)); } /// Every committed schema file that carries a `TaskSourceLabel` def, - /// paired with the schema version its label convention follows. - const COMMITTED_SCHEMAS_WITH_TASK_SOURCE_LABEL: &[(&str, u32)] = &[ - ("schemas/doctor.v1.schema.json", 1), - ("schemas/doctor.v2.schema.json", 2), - ("schemas/doctor.v3.schema.json", 3), - ("schemas/list.v1.schema.json", 1), - ("schemas/list.v2.schema.json", 2), - ("schemas/why.v1.schema.json", 1), - ("schemas/why.v2.schema.json", 2), - ("schemas/why.v3.schema.json", 3), + /// paired with whether its source labels follow the structured + /// (`doctor`/`why`) or flat (`list`) convention. + const COMMITTED_SCHEMAS_WITH_TASK_SOURCE_LABEL: &[(&str, &str)] = &[ + ("schemas/doctor.schema.json", "doctor"), + ("schemas/list.schema.json", "list"), + ("schemas/why.schema.json", "why"), ]; - fn runtime_labels(version: u32) -> Vec<&'static str> { - use crate::schema::labels::source_label_for; + fn runtime_labels(command: &str) -> Vec<&'static str> { + use crate::schema::labels::{flat_source_label, structured_source_label}; use crate::types::TaskSource; TaskSource::all() .iter() - .map(|&source| source_label_for(source, version)) + .map(|&source| { + if command == "list" { + flat_source_label(source) + } else { + structured_source_label(source) + } + }) .collect() } #[test] - fn task_source_label_schema_matches_runtime_labels_per_version() { - // source_labels(version) used to be three hand-maintained arrays, - // free to drift from the source_label_for dispatcher `why`/`doctor` + fn task_source_label_schema_matches_runtime_labels_per_command() { + // source_labels(command) used to be three hand-maintained arrays, + // free to drift from the label functions `list`/`why`/`doctor` // actually call at runtime. Now that it's derived, this test is a // tautology against today's implementation — its job is to catch a // future regression back to a hardcoded list. - for version in 1..=3 { - let schema = super::task_source_label_schema(version); + for command in ["list", "doctor", "why"] { + let schema = super::task_source_label_schema(command); let enum_values: Vec<&str> = schema["enum"] .as_array() - .unwrap_or_else(|| panic!("v{version}: expected enum array")) + .unwrap_or_else(|| panic!("{command}: expected enum array")) .iter() .map(|v| v.as_str().expect("enum values should be strings")) .collect(); assert_eq!( enum_values, - runtime_labels(version), - "v{version}: schema TaskSourceLabel enum must match source_label_for exactly" + runtime_labels(command), + "{command}: schema TaskSourceLabel enum must match the runtime label function" ); } } @@ -426,7 +405,7 @@ mod tests { // that defines TaskSourceLabel directly off disk so a stale // artifact — the generator fixed, the commit forgotten — fails // here instead of shipping silently. - for &(path, version) in COMMITTED_SCHEMAS_WITH_TASK_SOURCE_LABEL { + for &(path, command) in COMMITTED_SCHEMAS_WITH_TASK_SOURCE_LABEL { let raw = std::fs::read_to_string(path) .unwrap_or_else(|err| panic!("{path}: should be readable: {err}")); let schema: Value = serde_json::from_str(&raw) @@ -439,9 +418,9 @@ mod tests { .collect(); assert_eq!( enum_values, - runtime_labels(version), - "{path}: committed TaskSourceLabel enum has drifted from source_label_for — run \ - `just gen-schema` and commit the result" + runtime_labels(command), + "{path}: committed TaskSourceLabel enum has drifted from the runtime label \ + function — run `just gen-schema` and commit the result" ); } } diff --git a/src/cmd/why.rs b/src/cmd/why.rs index 985853d5..057bb20d 100644 --- a/src/cmd/why.rs +++ b/src/cmd/why.rs @@ -30,7 +30,6 @@ pub(crate) fn why( overrides: &ResolutionOverrides, task: &str, json: bool, - schema_version: u32, ) -> Result<()> { // Interpret the token exactly like `run` does — qualified syntax // (`deno:lint`), FQN (`root:package.json#name`), and the exact-name @@ -74,18 +73,7 @@ pub(crate) fn why( let pm_decision = pm_decision_for_selected(ctx, overrides, selected); - 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 { + if json { let report = build_report( task, &candidates, @@ -93,7 +81,6 @@ pub(crate) fn why( pm_decision.as_ref(), overrides, ctx, - schema_version, ); println!("{}", serde_json::to_string_pretty(&report)?); } else { @@ -115,40 +102,6 @@ enum PmDecision { Python(Result), } -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[derive(Debug, Serialize)] -pub(super) struct WhyReport<'a> { - #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] - #[cfg_attr( - feature = "schema", - schemars(description = "URI of the JSON Schema that describes this payload.") - )] - schema: String, - #[cfg_attr( - feature = "schema", - schemars(description = "Schema contract version for this JSON payload.") - )] - schema_version: u32, - task: &'a str, - candidates: Vec>, - selected: Option>, - pm_resolution: Option, -} - -#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[derive(Debug, Serialize)] -struct WhyCandidate<'a> { - source: &'static str, - source_priority: u16, - depth: Option, - display_order: u8, - is_alias: bool, - alias_of: Option<&'a str>, - description: Option<&'a str>, - passthrough_to: Option<&'static str>, - source_dir: Option, -} - #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[serde(untagged)] @@ -190,28 +143,6 @@ fn pm_decision_for_selected( } } -fn build_report<'a>( - task: &'a str, - candidates: &[&'a Task], - selected: Option<&'a Task>, - pm_decision: Option<&PmDecision>, - overrides: &ResolutionOverrides, - ctx: &ProjectContext, - schema_version: u32, -) -> WhyReport<'a> { - WhyReport { - schema: String::new(), - schema_version, - task, - candidates: candidates - .iter() - .map(|candidate| candidate_json(candidate, overrides, ctx, schema_version)) - .collect::>(), - selected: selected.map(|task| candidate_json(task, overrides, ctx, schema_version)), - pm_resolution: pm_decision.map(pm_resolution), - } -} - fn pm_resolution(decision: &PmDecision) -> PmResolution { match decision { PmDecision::Node(Ok(decision)) => PmResolution::Resolved { @@ -238,38 +169,12 @@ fn pm_resolution(decision: &PmDecision) -> PmResolution { } } -fn candidate_json<'a>( - task: &'a Task, - overrides: &ResolutionOverrides, - ctx: &ProjectContext, - schema_version: u32, -) -> WhyCandidate<'a> { - let depth = source_depth(ctx, task.source); - let depth = if depth == usize::MAX { - None - } else { - Some(depth) - }; - WhyCandidate { - source: labels::source_label_for(task.source, schema_version), - source_priority: source_priority(overrides, task.source), - depth, - display_order: task.source.display_order(), - is_alias: task.alias_of.is_some(), - alias_of: task.alias_of.as_deref(), - description: task.description.as_deref(), - passthrough_to: task.passthrough_to.map(crate::types::TaskRunner::label), - source_dir: labels::source_anchor(task.source, &ctx.root) - .map(|path| path.display().to_string()), - } -} - -/// `runner why --json --schema-version 3` payload. Field order mirrors -/// the committed `schemas/why.v3.example.json`. +/// `runner why --json` payload. Field order mirrors the committed +/// `schemas/why.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> { +pub(super) struct WhyReport<'a> { #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] #[cfg_attr( feature = "schema", @@ -297,25 +202,25 @@ pub(super) struct WhyReportV3<'a> { )] query: &'a str, pm_resolution: Option, - selected: Option>, - candidates: Vec>, - decision: WhyDecisionV3, + selected: Option>, + candidates: Vec>, + decision: WhyDecision, } /// 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>, +struct WhyCandidate<'a> { + task: WhyTask<'a>, #[serde(rename = "match")] - matched: WhyMatchV3<'a>, + matched: WhyMatch<'a>, } #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct WhyTaskV3<'a> { +struct WhyTask<'a> { name: &'a str, #[cfg_attr( feature = "schema", @@ -335,7 +240,9 @@ struct WhyTaskV3<'a> { provider: &'static str, #[cfg_attr( feature = "schema", - schemars(description = "Task mechanism label (v3 source label, e.g. `cargo-alias`).") + schemars( + description = "Task mechanism label (structured source label, e.g. `cargo-alias`)." + ) )] kind: &'static str, #[cfg_attr( @@ -386,7 +293,7 @@ struct WhyTaskV3<'a> { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct WhyMatchV3<'a> { +struct WhyMatch<'a> { selector: &'a str, #[cfg_attr( feature = "schema", @@ -403,7 +310,7 @@ struct WhyMatchV3<'a> { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct WhyDecisionV3 { +struct WhyDecision { #[cfg_attr( feature = "schema", schemars(description = "Selection branch taken: `single-candidate`, `ranked`, \ @@ -413,44 +320,42 @@ struct WhyDecisionV3 { reason: String, } -fn build_report_v3<'a>( +fn build_report<'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), +) -> WhyReport<'a> { + let candidate_report = |task: &'a Task| WhyCandidate { + task: task_report(task, ctx, pm_decision, selected), + matched: match_report(query, task, overrides, ctx), }; - WhyReportV3 { - schema: String::new(), - schema_version, + WhyReport { + schema: crate::schema::schema_url("why"), + schema_version: crate::schema::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), + selected: selected.map(candidate_report), + candidates: candidates.iter().copied().map(candidate_report).collect(), + decision: decision_report(candidates, selected), } } -fn task_v3<'a>( +fn task_report<'a>( task: &'a Task, ctx: &'a ProjectContext, pm_decision: Option<&PmDecision>, selected: Option<&Task>, - schema_version: u32, -) -> WhyTaskV3<'a> { - let kind = labels::source_label_for(task.source, schema_version); +) -> WhyTask<'a> { + let kind = labels::structured_source_label(task.source); let is_selected = selected.is_some_and(|sel| std::ptr::eq(sel, task)); - WhyTaskV3 { + WhyTask { name: &task.name, - fqn: labels::fqn(task.source, &task.name, schema_version), + fqn: labels::fqn(task.source, &task.name), provider: provider_label(task.source), kind, source: labels::source_anchor(task.source, &ctx.root) @@ -472,14 +377,14 @@ fn task_v3<'a>( } } -fn match_v3<'a>( +fn match_report<'a>( selector: &'a str, task: &Task, overrides: &ResolutionOverrides, ctx: &ProjectContext, -) -> WhyMatchV3<'a> { +) -> WhyMatch<'a> { let depth = source_depth(ctx, task.source); - WhyMatchV3 { + WhyMatch { selector, matched_by: "name", depth: (depth != usize::MAX).then_some(depth), @@ -490,9 +395,9 @@ fn match_v3<'a>( } } -fn decision_v3(candidates: &[&Task], selected: Option<&Task>) -> WhyDecisionV3 { +fn decision_report(candidates: &[&Task], selected: Option<&Task>) -> WhyDecision { if candidates.is_empty() { - return WhyDecisionV3 { + return WhyDecision { strategy: "exec-fallback", reason: "no task matched; `runner run` would route the name through the primary \ package manager's exec primitive" @@ -500,19 +405,19 @@ fn decision_v3(candidates: &[&Task], selected: Option<&Task>) -> WhyDecisionV3 { }; } if selected.is_none() { - return WhyDecisionV3 { + return WhyDecision { strategy: "filtered", reason: "every candidate was filtered out by --runner/RUNNER_RUNNER restrictions" .to_string(), }; } if candidates.len() == 1 { - return WhyDecisionV3 { + return WhyDecision { strategy: "single-candidate", reason: "exact task name matched one candidate".to_string(), }; } - WhyDecisionV3 { + WhyDecision { strategy: "ranked", reason: format!( "{} candidates; lowest (source_priority, source_depth, display_order, alias-last) key \ @@ -523,7 +428,7 @@ fn decision_v3(candidates: &[&Task], selected: Option<&Task>) -> WhyDecisionV3 { } /// Tool family that executes tasks from this source. Distinct from the -/// v3 `kind` label, which names the extraction mechanism. +/// structured `kind` label, which names the extraction mechanism. const fn provider_label(source: TaskSource) -> &'static str { match source { TaskSource::PackageJson => "node", @@ -543,7 +448,7 @@ const fn provider_label(source: TaskSource) -> &'static str { /// Effective command preview for the candidate. `why` only resolves the /// PM for the selected task — other candidates report null. Delegates the /// per-source dispatch to [`labels::resolved_command`], shared with -/// `doctor` v3. +/// `doctor`. fn resolved_command(task: &Task, pm_decision: Option<&PmDecision>) -> Option { let node_pm = match pm_decision { Some(PmDecision::Node(Ok(decision))) => Some(decision.pm.label()), @@ -645,7 +550,7 @@ fn print_human( mod tests { use std::path::PathBuf; - use super::{PmDecision, build_report, build_report_v3, pm_decision_for_selected, why}; + use super::{PmDecision, build_report, pm_decision_for_selected, why}; use crate::resolver::{DiagnosticFlags, ResolutionOverrides}; use crate::types::{PackageManager, ProjectContext, Task, TaskSource}; @@ -676,14 +581,8 @@ mod tests { #[test] fn why_handles_missing_task() { let ctx = context(vec![]); - why( - &ctx, - &ResolutionOverrides::default(), - "build", - true, - crate::schema::CURRENT_VERSION, - ) - .expect("why should succeed even when task is missing"); + why(&ctx, &ResolutionOverrides::default(), "build", true) + .expect("why should succeed even when task is missing"); } #[test] @@ -692,23 +591,8 @@ mod tests { task("build", TaskSource::PackageJson), task("build", TaskSource::Justfile), ]); - let version = crate::schema::CURRENT_VERSION; - why( - &ctx, - &ResolutionOverrides::default(), - "build", - true, - version, - ) - .expect("json should succeed"); - why( - &ctx, - &ResolutionOverrides::default(), - "build", - false, - version, - ) - .expect("human should succeed"); + why(&ctx, &ResolutionOverrides::default(), "build", true).expect("json should succeed"); + why(&ctx, &ResolutionOverrides::default(), "build", false).expect("human should succeed"); } #[test] @@ -725,65 +609,58 @@ mod tests { ) .expect("runner override should parse"); - let err = why( - &ctx, - &overrides, - "build", - true, - crate::schema::CURRENT_VERSION, - ) - .expect_err("why should mirror run runner constraints"); + let err = why(&ctx, &overrides, "build", true) + .expect_err("why should mirror run runner constraints"); assert!(format!("{err}").contains("no candidate task is registered")); } #[test] - fn why_pyproject_script_reports_detected_python_pm() { - let mut ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]); - ctx.package_managers.push(PackageManager::Uv); + fn why_pyproject_script_reports_python_pm_override() { + let ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]); + let overrides = ResolutionOverrides::from_cli_and_env( + Some("uv"), + None, + None, + None, + DiagnosticFlags::default(), + crate::cli::ChainFailureFlags::default(), + None, + ) + .expect("PM override should parse"); let selected = ctx.tasks.first(); - let pm_decision = pm_decision_for_selected(&ctx, &ResolutionOverrides::default(), selected) + let pm_decision = pm_decision_for_selected(&ctx, &overrides, selected) .expect("pyproject task should resolve PM diagnostics"); - let report = build_report( - "greenpy", - &[&ctx.tasks[0]], - selected, - Some(&pm_decision), - &ResolutionOverrides::default(), - &ctx, - crate::schema::CURRENT_VERSION, - ); - let report = serde_json::to_value(report).expect("why report should serialize"); - - assert_eq!(report["pm_resolution"]["pm"], serde_json::json!("uv")); - assert!( - report["pm_resolution"]["via"] - .as_str() - .is_some_and(|via| via.contains("detected Python project")) - ); + match pm_decision { + PmDecision::Python(Ok(decision)) => { + assert_eq!(decision.pm, PackageManager::Uv); + assert!(decision.describe().contains("--pm")); + } + PmDecision::Python(Err(err)) => panic!("override should resolve: {err}"), + PmDecision::Node(_) => panic!("pyproject script should use Python PM resolver"), + } } #[test] - fn v3_report_describes_cargo_alias_like_the_committed_example() { + fn 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( + let report = build_report( "t", &candidates, selected, None, &ResolutionOverrides::default(), &ctx, - 3, ); - let json = serde_json::to_value(&report).expect("v3 report should serialize"); + let json = serde_json::to_value(&report).expect("report should serialize"); - assert_eq!(json["schema_version"], 3); + assert_eq!(json["schema_version"], 1); assert_eq!(json["kind"], "runner.why"); assert_eq!(json["query"], "t"); assert_eq!(json["pm_resolution"], serde_json::Value::Null); @@ -812,18 +689,17 @@ mod tests { } #[test] - fn v3_report_uses_exec_fallback_decision_when_nothing_matches() { + fn report_uses_exec_fallback_decision_when_nothing_matches() { let ctx = context(vec![]); - let report = build_report_v3( + let report = build_report( "nope", &[], None, None, &ResolutionOverrides::default(), &ctx, - 3, ); - let json = serde_json::to_value(&report).expect("v3 report should serialize"); + let json = serde_json::to_value(&report).expect("report should serialize"); assert_eq!(json["selected"], serde_json::Value::Null); assert_eq!(json["candidates"], serde_json::json!([])); @@ -831,22 +707,21 @@ mod tests { } #[test] - fn v3_report_ranks_multiple_candidates() { + fn 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( + let report = build_report( "build", &candidates, ctx.tasks.first(), None, &ResolutionOverrides::default(), &ctx, - 3, ); - let json = serde_json::to_value(&report).expect("v3 report should serialize"); + let json = serde_json::to_value(&report).expect("report should serialize"); assert_eq!(json["decision"]["strategy"], "ranked"); assert_eq!(json["candidates"].as_array().map(Vec::len), Some(2)); @@ -860,7 +735,7 @@ mod tests { } #[test] - fn v3_report_resolves_selected_pyproject_script_through_python_pm() { + fn 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(); @@ -868,16 +743,15 @@ mod tests { .expect("pyproject task should resolve PM diagnostics"); let candidates = vec![&ctx.tasks[0]]; - let report = build_report_v3( + let report = build_report( "greenpy", &candidates, selected, Some(&pm_decision), &ResolutionOverrides::default(), &ctx, - 3, ); - let json = serde_json::to_value(&report).expect("v3 report should serialize"); + let json = serde_json::to_value(&report).expect("report should serialize"); assert_eq!(json["selected"]["task"]["provider"], "python"); assert_eq!(json["selected"]["task"]["resolved"], "uv run greenpy"); @@ -888,53 +762,25 @@ mod tests { } #[test] - fn v3_report_collects_sibling_aliases() { + fn 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( + let report = build_report( "fmt", &candidates, ctx.tasks.first(), None, &ResolutionOverrides::default(), &ctx, - 3, ); - let json = serde_json::to_value(&report).expect("v3 report should serialize"); + let json = serde_json::to_value(&report).expect("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)]); - let overrides = ResolutionOverrides::from_cli_and_env( - Some("uv"), - None, - None, - None, - DiagnosticFlags::default(), - crate::cli::ChainFailureFlags::default(), - None, - ) - .expect("PM override should parse"); - let selected = ctx.tasks.first(); - let pm_decision = pm_decision_for_selected(&ctx, &overrides, selected) - .expect("pyproject task should resolve PM diagnostics"); - - match pm_decision { - PmDecision::Python(Ok(decision)) => { - assert_eq!(decision.pm, PackageManager::Uv); - assert!(decision.describe().contains("--pm")); - } - PmDecision::Python(Err(err)) => panic!("override should resolve: {err}"), - PmDecision::Node(_) => panic!("pyproject script should use Python PM resolver"), - } - } } diff --git a/src/lib.rs b/src/lib.rs index 8f8383ae..642dfb76 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -811,7 +811,7 @@ fn run_path_builtin_fallback( // `info` maps to a plain `list`: the deprecation warning is specific // to the explicit `runner info` subcommand, not the run path. "list" | "info" => { - cmd::list(ctx, overrides, false, false, None, schema::CURRENT_VERSION)?; + cmd::list(ctx, overrides, false, false, None)?; 0 } "completions" => { @@ -823,39 +823,15 @@ fn run_path_builtin_fallback( Ok(Some(code)) } -/// Resolve the effective JSON schema version for schema-aware output: -/// explicit `--schema-version=N` wins, otherwise default to latest. -fn resolve_schema_version(requested: Option) -> Result { - schema::validate_schema_version(requested.unwrap_or(schema::CURRENT_VERSION)) -} - +/// Validate `--schema-version=N` for schema-aware (`--json`) output. +/// `clap` already bounds the flag to [`schema::SCHEMA_VERSION`]; this is a +/// defensive second check so a future non-CLI caller can't slip an +/// unsupported version past the JSON-producing commands. fn schema_version_for_json(json: bool, requested: Option) -> Result { if json { - resolve_schema_version(requested) - } else { - Ok(schema::CURRENT_VERSION) - } -} - -/// `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)) + schema::validate_schema_version(requested.unwrap_or(schema::SCHEMA_VERSION)) } else { - Ok(schema::DOCTOR_CURRENT_VERSION) + Ok(schema::SCHEMA_VERSION) } } @@ -989,7 +965,7 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { let overrides = dispatch_overrides(&cli, loaded_config.as_ref(), &mut ctx)?; match cli.command { - None => cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION).map(|()| 0), + None => cmd::info(&ctx, &overrides, false).map(|()| 0), // `info` is a deprecated alias for `list`. Bare `runner` (the // `None` arm above) keeps the dashboard; only the explicit verb // is deprecated. @@ -1008,8 +984,8 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { "::warning title=Deprecation::`runner info` is deprecated; use `runner list`" ); } - let schema_version = schema_version_for_json(json, cli.global.schema_version)?; - cmd::list(&ctx, &overrides, false, json, None, schema_version)?; + schema_version_for_json(json, cli.global.schema_version)?; + cmd::list(&ctx, &overrides, false, json, None)?; Ok(0) } Some(cli::Command::Run { @@ -1017,7 +993,7 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { }) => dispatch_run(&ctx, &overrides, task, args, mode), Some(cli::Command::External(args)) => { if args.is_empty() { - cmd::info(&ctx, &overrides, false, schema::CURRENT_VERSION)?; + cmd::info(&ctx, &overrides, false)?; Ok(0) } else { cmd::run(&ctx, &overrides, &args[0], &args[1..], None) @@ -1054,15 +1030,8 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { Ok(0) } Some(cli::Command::List { raw, json, source }) => { - let schema_version = schema_version_for_json(json, cli.global.schema_version)?; - cmd::list( - &ctx, - &overrides, - raw, - json, - source.as_deref(), - schema_version, - )?; + schema_version_for_json(json, cli.global.schema_version)?; + cmd::list(&ctx, &overrides, raw, json, source.as_deref())?; Ok(0) } Some(cli::Command::Completions { shell, output }) => { @@ -1076,14 +1045,14 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { #[cfg(feature = "lsp")] Some(cli::Command::Lsp) => cmd::lsp::run(), // intercepted pre-detection Some(cli::Command::Doctor { json }) => { - let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?; - cmd::doctor(&ctx, &overrides, json, schema_version)?; + schema_version_for_json(json, cli.global.schema_version)?; + cmd::doctor(&ctx, &overrides, json)?; Ok(0) } Some(cli::Command::Config { action }) => cmd::config(dir, action), Some(cli::Command::Why { task, json }) => { - let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?; - cmd::why(&ctx, &overrides, &task, json, schema_version)?; + schema_version_for_json(json, cli.global.schema_version)?; + cmd::why(&ctx, &overrides, &task, json)?; Ok(0) } } diff --git a/src/schema/doctor_v3.rs b/src/schema/doctor.rs similarity index 83% rename from src/schema/doctor_v3.rs rename to src/schema/doctor.rs index 2c1d536c..340fa181 100644 --- a/src/schema/doctor_v3.rs +++ b/src/schema/doctor.rs @@ -1,42 +1,37 @@ -//! `doctor --json` schema **v3** — the structured diagnostic report. +//! `doctor --json` schema — 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. +//! A structured 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: +//! Notes on the shape: //! //! - `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 +//! fail, and a source anchor file can be undiscoverable. +//! - `sources[].kind` uses the structured source labels (`cargo-alias`, +//! `just`, …) shared with `why`, not the flat `list`/`info` labels. +//! - `overrides.pm`/`overrides.runner` are bare labels; the provenance +//! (`cli`/`env`/`config:…`) remains available on the flat `list`/`info` //! 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. +//! - 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; use serde::Serialize; -use super::labels::source_label_for; +use super::labels::structured_source_label; use crate::cmd::run::{resolve_python_pm, select_task_entry, source_depth, source_priority}; use crate::resolver::{ FallbackPolicy, MismatchPolicy, ResolutionOverrides, ResolutionStep, Resolver, @@ -44,11 +39,11 @@ use crate::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. +/// `runner doctor --json` payload. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -pub(crate) struct DoctorReportV3<'a> { +pub(crate) struct DoctorReport<'a> { #[serde(rename = "$schema")] #[cfg_attr( feature = "schema", @@ -65,25 +60,25 @@ pub(crate) struct DoctorReportV3<'a> { 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, + invocation: Invocation, + environment: Environment, + runner: RunnerInfo, + project: ProjectInfo, + overrides: Overrides, + ecosystems: Vec, + sources: Vec, + tasks: Vec>, + tools: Vec, + conflicts: Vec, + diagnostics: Vec, + resolution: ResolutionPolicy, } /// 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 { +struct Invocation { argv: Vec, cwd: String, #[cfg_attr( @@ -97,7 +92,7 @@ struct InvocationV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct EnvironmentV3 { +struct Environment { arch: &'static str, os: &'static str, path_entries: Vec, @@ -108,18 +103,18 @@ struct EnvironmentV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct RunnerInfoV3 { +struct RunnerInfo { binary: String, name: String, version: &'static str, - schema_versions: SchemaVersionsV3, + schema_versions: SchemaVersions, } /// 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 { +struct SchemaVersions { doctor: u32, list: u32, why: u32, @@ -129,7 +124,7 @@ struct SchemaVersionsV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct ProjectInfoV3 { +struct ProjectInfo { monorepo: bool, root: String, #[cfg_attr( @@ -151,11 +146,11 @@ struct ProjectInfoV3 { } /// Effective override stack, labels only. Provenance (cli/env/config) -/// stays on the v2 surface. +/// stays on the flat `list`/`info` surface. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct OverridesV3 { +struct Overrides { explain: bool, fallback: &'static str, no_warnings: bool, @@ -171,8 +166,8 @@ struct OverridesV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct EcosystemV3 { - decision: EcosystemDecisionV3, +struct EcosystemEntry { + decision: EcosystemDecision, name: &'static str, root: String, selected_package_manager: Option<&'static str>, @@ -189,8 +184,8 @@ struct EcosystemV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct EcosystemDecisionV3 { - confidence: ConfidenceV3, +struct EcosystemDecision { + confidence: Confidence, reason: String, selected: Option<&'static str>, } @@ -199,7 +194,7 @@ struct EcosystemDecisionV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[serde(rename_all = "lowercase")] -enum ConfidenceV3 { +enum Confidence { /// Explicit signal: override, manifest declaration, or lockfile. High, /// Inferred: PATH probe found a usable binary. @@ -214,7 +209,7 @@ enum ConfidenceV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct SourceV3 { +struct SourceEntry { exists: bool, #[cfg_attr( feature = "schema", @@ -223,7 +218,7 @@ struct SourceV3 { id: String, #[cfg_attr( feature = "schema", - schemars(description = "v3 source label (same convention as `why` v3).") + schemars(description = "Structured source label (same convention as `why`).") )] kind: &'static str, #[cfg_attr( @@ -248,12 +243,12 @@ struct SourceV3 { task_pointer: Option<&'static str>, } -/// One task in the doctor inventory. Same identity scheme as `why` v3 +/// One task in the doctor inventory. Same identity scheme as `why` /// (`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> { +struct DoctorTask<'a> { aliases: Vec<&'a str>, cwd: String, definition: Option<&'a str>, @@ -302,13 +297,13 @@ struct DoctorTaskV3<'a> { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "kebab-case")] -enum DependencyKindV3 { +enum DependencyKind { Runtime, PackageManager, TaskRunner, } -impl DependencyKindV3 { +impl DependencyKind { const fn label(self) -> &'static str { match self { Self::Runtime => "runtime", @@ -322,15 +317,15 @@ impl DependencyKindV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct ToolV3 { +struct Tool { #[cfg_attr( feature = "schema", schemars(description = "Stable tool identity: `tool::`.") )] id: String, - kind: DependencyKindV3, + kind: DependencyKind, name: &'static str, - probe: ToolProbeV3, + probe: ToolProbe, required: bool, } @@ -339,7 +334,7 @@ struct ToolV3 { #[derive(Debug, Serialize)] #[serde(tag = "status", rename_all = "lowercase")] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -enum ToolProbeV3 { +enum ToolProbe { Found { path: String, #[cfg_attr( @@ -357,13 +352,13 @@ enum ToolProbeV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct ConflictV3 { +struct Conflict { kind: &'static str, reason: String, #[cfg_attr(feature = "schema", schemars(description = "FQN of the winning task."))] selected: String, selector: String, - severity: SeverityV3, + severity: Severity, shadowed: Vec, } @@ -372,7 +367,7 @@ struct ConflictV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "lowercase")] -enum SeverityV3 { +enum Severity { Info, Warning, } @@ -382,14 +377,14 @@ enum SeverityV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct DiagnosticV3 { +struct Diagnostic { #[cfg_attr( feature = "schema", schemars(description = "Stable warning category (the warning's source subsystem).") )] code: &'static str, message: String, - severity: SeverityV3, + severity: Severity, source: Option<&'static str>, task: Option, } @@ -399,51 +394,50 @@ struct DiagnosticV3 { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[cfg_attr(feature = "schema", schemars(deny_unknown_fields))] -struct ResolutionPolicyV3 { +struct ResolutionPolicy { 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. +impl<'a> DoctorReport<'a> { + /// Build the report. `resolve_shims` is forwarded to the Volta shim + /// classifier exactly like the flat `list`/`info` 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) + .map(diagnostic) .collect(); Self { - schema: super::schema_url("doctor", schema_version), - schema_version, + schema: super::schema_url("doctor"), + schema_version: super::SCHEMA_VERSION, kind: "runner.doctor", - invocation: invocation_v3(), - environment: environment_v3(), - runner: runner_info_v3(), - project: ProjectInfoV3 { + invocation: invocation(), + environment: environment(), + runner: runner_info(), + project: ProjectInfo { 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, overrides, &node_pm), - conflicts: conflicts_v3(ctx, overrides, schema_version), + overrides: overrides_report(overrides), + ecosystems: ecosystems(ctx, overrides, &node_pm, resolve_shims), + sources: sources(ctx), + tasks: tasks(ctx, &node_pm, overrides), + tools: tools(ctx, overrides, &node_pm), + conflicts: conflicts(ctx, overrides), diagnostics, - resolution: ResolutionPolicyV3 { + resolution: ResolutionPolicy { fqn_policy: "exact-only", precedence: vec![ "source-priority", @@ -457,8 +451,8 @@ impl<'a> DoctorReportV3<'a> { } } -fn invocation_v3() -> InvocationV3 { - InvocationV3 { +fn invocation() -> Invocation { + Invocation { argv: std::env::args().collect(), cwd: std::env::current_dir() .map(|d| d.display().to_string()) @@ -467,8 +461,8 @@ fn invocation_v3() -> InvocationV3 { } } -fn environment_v3() -> EnvironmentV3 { - EnvironmentV3 { +fn environment() -> Environment { + Environment { arch: std::env::consts::ARCH, os: std::env::consts::OS, path_entries: std::env::var_os("PATH") @@ -482,27 +476,27 @@ fn environment_v3() -> EnvironmentV3 { } } -fn runner_info_v3() -> RunnerInfoV3 { +fn runner_info() -> RunnerInfo { 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 { + RunnerInfo { binary, name, version: env!("CARGO_PKG_VERSION"), - schema_versions: SchemaVersionsV3 { - doctor: super::DOCTOR_CURRENT_VERSION, - list: super::CURRENT_VERSION, - why: super::WHY_CURRENT_VERSION, + schema_versions: SchemaVersions { + doctor: super::SCHEMA_VERSION, + list: super::SCHEMA_VERSION, + why: super::SCHEMA_VERSION, }, } } -fn overrides_v3(overrides: &ResolutionOverrides) -> OverridesV3 { - OverridesV3 { +fn overrides_report(overrides: &ResolutionOverrides) -> Overrides { + Overrides { explain: overrides.explain, fallback: match overrides.fallback { FallbackPolicy::Probe => "probe", @@ -527,12 +521,12 @@ fn overrides_v3(overrides: &ResolutionOverrides) -> OverridesV3 { } } -fn ecosystems_v3( +fn ecosystems( ctx: &ProjectContext, overrides: &ResolutionOverrides, node_pm: &Result, resolve_shims: bool, -) -> Vec { +) -> Vec { let mut seen = Vec::new(); for pm in &ctx.package_managers { let eco = pm.ecosystem(); @@ -544,10 +538,10 @@ fn ecosystems_v3( // 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 + // fallback, override). In those cases `tasks` 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`. + // node runtime entry in `tools`. if has_node_context(ctx, node_pm) && !seen.contains(&Ecosystem::Node) { seen.push(Ecosystem::Node); } @@ -560,9 +554,9 @@ fn ecosystems_v3( 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), + Ecosystem::Node => node_ecosystem(ctx, node_pm, resolve_shims), + Ecosystem::Python => python_ecosystem(ctx, overrides), + other => single_pm_ecosystem(ctx, other), }) .collect() } @@ -571,8 +565,8 @@ fn ecosystems_v3( /// 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. +/// both [`ecosystems`] and [`tools`] so the two surfaces never +/// disagree with what `tasks` resolves. fn has_node_context( ctx: &ProjectContext, node_pm: &Result, @@ -590,8 +584,8 @@ fn has_node_context( /// Whether the project carries Python context, considering resolver and /// task signals — not just lockfile-detected `package_managers`. Mirrors -/// [`has_node_context`]; gates Python inclusion in both [`ecosystems_v3`] -/// and [`tools_v3`] so neither surface disagrees with what `tasks_v3` +/// [`has_node_context`]; gates Python inclusion in both [`ecosystems`] +/// and [`tools`] so neither surface disagrees with what `tasks` /// resolves. fn has_python_context(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> bool { resolve_python_pm(ctx, overrides).is_some() @@ -605,14 +599,14 @@ fn has_python_context(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> .any(|t| matches!(t.source, TaskSource::PyprojectScripts)) } -fn node_ecosystem_v3( +fn node_ecosystem( ctx: &ProjectContext, node_pm: &Result, resolve_shims: bool, -) -> EcosystemV3 { +) -> EcosystemEntry { let (decision, selected) = match node_pm { Ok(decision) => ( - EcosystemDecisionV3 { + EcosystemDecision { confidence: confidence_for_step(&decision.via), reason: decision.describe(), selected: Some(decision.pm.label()), @@ -620,8 +614,8 @@ fn node_ecosystem_v3( Some(decision.pm.label()), ), Err(err) => ( - EcosystemDecisionV3 { - confidence: ConfidenceV3::None, + EcosystemDecision { + confidence: Confidence::None, reason: format!("{err}"), selected: None, }, @@ -634,8 +628,8 @@ fn node_ecosystem_v3( // 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.) + // change. (The flat `list`/`info` shape's `volta_shims` spelling is + // frozen; only this structured report gets the generic shape.) let shims = probes .volta_shims .iter() @@ -653,7 +647,7 @@ fn node_ecosystem_v3( "shims": shims, }); - EcosystemV3 { + EcosystemEntry { decision, name: "node", root: ctx.root.display().to_string(), @@ -662,13 +656,13 @@ fn node_ecosystem_v3( } } -fn python_ecosystem_v3(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> EcosystemV3 { +fn python_ecosystem(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> EcosystemEntry { let resolved = resolve_python_pm(ctx, overrides); let (decision, selected) = resolved.map_or_else( || { ( - EcosystemDecisionV3 { - confidence: ConfidenceV3::None, + EcosystemDecision { + confidence: Confidence::None, reason: "no Python package manager detected".to_string(), selected: None, }, @@ -678,8 +672,8 @@ fn python_ecosystem_v3(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> |decision| { let label = decision.pm.label(); ( - EcosystemDecisionV3 { - confidence: ConfidenceV3::High, + EcosystemDecision { + confidence: Confidence::High, reason: decision.describe(), selected: Some(label), }, @@ -688,7 +682,7 @@ fn python_ecosystem_v3(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> }, ); - EcosystemV3 { + EcosystemEntry { decision, name: "python", root: ctx.root.display().to_string(), @@ -699,16 +693,16 @@ fn python_ecosystem_v3(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> /// 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 { +fn single_pm_ecosystem(ctx: &ProjectContext, eco: Ecosystem) -> EcosystemEntry { let selected = ctx .package_managers .iter() .find(|pm| pm.ecosystem() == eco) .map(|pm| pm.label()); - EcosystemV3 { - decision: EcosystemDecisionV3 { - confidence: ConfidenceV3::High, + EcosystemEntry { + decision: EcosystemDecision { + confidence: Confidence::High, reason: format!( "detected via {} project signal", selected.unwrap_or("manifest") @@ -733,18 +727,18 @@ fn detected_pm_signals(ctx: &ProjectContext, eco: Ecosystem) -> serde_json::Valu }) } -const fn confidence_for_step(step: &ResolutionStep) -> ConfidenceV3 { +const fn confidence_for_step(step: &ResolutionStep) -> Confidence { match step { ResolutionStep::Override(_) | ResolutionStep::ManifestPackageManager | ResolutionStep::ManifestDevEngines { .. } - | ResolutionStep::Lockfile => ConfidenceV3::High, - ResolutionStep::PathProbe { .. } => ConfidenceV3::Medium, - ResolutionStep::LegacyNpmFallback => ConfidenceV3::Low, + | ResolutionStep::Lockfile => Confidence::High, + ResolutionStep::PathProbe { .. } => Confidence::Medium, + ResolutionStep::LegacyNpmFallback => Confidence::Low, } } -fn sources_v3(ctx: &ProjectContext, schema_version: u32) -> Vec { +fn sources(ctx: &ProjectContext) -> Vec { let mut seen: Vec = Vec::new(); for task in &ctx.tasks { if !seen.contains(&task.source) { @@ -754,7 +748,7 @@ fn sources_v3(ctx: &ProjectContext, schema_version: u32) -> Vec { seen.into_iter() .map(|source| { - let kind = source_label_for(source, schema_version); + let kind = structured_source_label(source); let anchor = super::labels::source_anchor(source, &ctx.root); let path = anchor .as_ref() @@ -762,7 +756,7 @@ fn sources_v3(ctx: &ProjectContext, schema_version: u32) -> Vec { let relpath = anchor.as_ref().map_or_else(String::new, |p| { p.strip_prefix(&ctx.root).unwrap_or(p).display().to_string() }); - SourceV3 { + SourceEntry { exists: anchor.as_ref().is_some_and(|p| p.is_file()), id: format!("src:root:{kind}"), kind, @@ -776,12 +770,11 @@ fn sources_v3(ctx: &ProjectContext, schema_version: u32) -> Vec { .collect() } -fn tasks_v3<'a>( +fn tasks<'a>( ctx: &'a ProjectContext, node_pm: &Result, overrides: &ResolutionOverrides, - schema_version: u32, -) -> Vec> { +) -> 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()); @@ -797,7 +790,7 @@ fn tasks_v3<'a>( ctx.tasks .iter() - .map(|task| DoctorTaskV3 { + .map(|task| DoctorTask { aliases: ctx .tasks .iter() @@ -810,7 +803,7 @@ fn tasks_v3<'a>( 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), + fqn: super::labels::fqn(task.source, &task.name), is_alias: task.alias_of.is_some(), name: &task.name, resolved: super::labels::resolved_command(task, node_pm_label, python_pm_label), @@ -850,11 +843,11 @@ const fn task_container_key(source: TaskSource) -> Option<&'static str> { } } -fn tools_v3( +fn tools( ctx: &ProjectContext, overrides: &ResolutionOverrides, node_pm: &Result, -) -> Vec { +) -> Vec { let path = std::env::var_os("PATH").unwrap_or_default(); let pathext = std::env::var_os("PATHEXT"); let pathext_ref = pathext.as_deref(); @@ -864,7 +857,7 @@ fn tools_v3( if has_node_context(ctx, node_pm) { tools.push(probe_tool( "node", - DependencyKindV3::Runtime, + DependencyKind::Runtime, ctx.current_node .as_deref() .map(|v| v.trim_start_matches('v').to_string()), @@ -881,7 +874,7 @@ fn tools_v3( tools.push(probe_tool( PYTHON_BIN, - DependencyKindV3::Runtime, + DependencyKind::Runtime, None, true, &path, @@ -907,7 +900,7 @@ fn tools_v3( }; tools.push(probe_tool( pm_binary_name(*pm), - DependencyKindV3::PackageManager, + DependencyKind::PackageManager, None, required, &path, @@ -917,7 +910,7 @@ fn tools_v3( for runner in &ctx.task_runners { tools.push(probe_tool( runner.label(), - DependencyKindV3::TaskRunner, + DependencyKind::TaskRunner, None, true, &path, @@ -939,22 +932,22 @@ const fn pm_binary_name(pm: PackageManager) -> &'static str { fn probe_tool( name: &'static str, - kind: DependencyKindV3, + kind: DependencyKind, version: Option, required: bool, path: &std::ffi::OsStr, pathext: Option<&std::ffi::OsStr>, -) -> ToolV3 { +) -> Tool { let probe = crate::resolver::probe_path_for_doctor(name, path, pathext).map_or( - ToolProbeV3::Missing, - |hit| ToolProbeV3::Found { + ToolProbe::Missing, + |hit| ToolProbe::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 { + Tool { id: format!("tool:{kind}:{name}", kind = kind.label()), kind, name, @@ -990,11 +983,7 @@ fn probe_tool_version(binary: &Path) -> Option { .map(ToString::to_string) } -fn conflicts_v3( - ctx: &ProjectContext, - overrides: &ResolutionOverrides, - schema_version: u32, -) -> Vec { +fn conflicts(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> 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); @@ -1005,8 +994,8 @@ fn conflicts_v3( .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 { + let fqn_of = |task: &Task| super::labels::fqn(task.source, &task.name); + Conflict { kind: "duplicate-task-name", reason: format!( "{count} sources define `{name}`; lowest (source_priority={priority}, \ @@ -1018,7 +1007,7 @@ fn conflicts_v3( ), selected: fqn_of(selected), selector: name.to_string(), - severity: SeverityV3::Info, + severity: Severity::Info, shadowed: group .iter() .filter(|task| !std::ptr::eq(**task, selected)) @@ -1037,11 +1026,11 @@ fn display_depth(depth: usize) -> String { } } -fn diagnostic_v3(warning: &DetectionWarning) -> DiagnosticV3 { - DiagnosticV3 { +fn diagnostic(warning: &DetectionWarning) -> Diagnostic { + Diagnostic { code: warning.source(), message: warning.detail(), - severity: SeverityV3::Warning, + severity: Severity::Warning, source: Some(warning.source()), task: None, } @@ -1088,7 +1077,7 @@ const fn civil_from_days(days: i64) -> (i64, i64, i64) { mod tests { use std::path::PathBuf; - use super::{DoctorReportV3, rfc3339_utc}; + use super::{DoctorReport, rfc3339_utc}; use crate::resolver::ResolutionOverrides; use crate::types::{Ecosystem, PackageManager, ProjectContext, Task, TaskSource}; @@ -1129,16 +1118,16 @@ mod tests { #[test] fn v3_report_carries_contract_constants() { let ctx = context(vec![]); - let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let report = DoctorReport::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_eq!(json["schema_version"], 1); assert_eq!(json["overrides"]["quiet"], serde_json::json!(false)); assert!( json["$schema"] .as_str() - .is_some_and(|s| s.contains("doctor.v3")) + .is_some_and(|s| s.contains("doctor.schema.json")) ); assert_eq!(json["resolution"]["fqn_policy"], "exact-only"); assert_eq!(json["project"]["workspace"], serde_json::Value::Null); @@ -1152,7 +1141,7 @@ mod tests { #[test] fn v3_report_lists_rust_ecosystem_with_high_confidence() { let ctx = context(vec![]); - let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let report = DoctorReport::build(&ctx, &ResolutionOverrides::default(), false); let json = serde_json::to_value(&report).expect("report should serialize"); let eco = &json["ecosystems"][0]; @@ -1166,7 +1155,7 @@ mod tests { 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 report = DoctorReport::build(&ctx, &ResolutionOverrides::default(), false); let json = serde_json::to_value(&report).expect("report should serialize"); let conflict = &json["conflicts"][0]; @@ -1186,7 +1175,7 @@ mod tests { 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 report = DoctorReport::build(&ctx, &ResolutionOverrides::default(), false); let json = serde_json::to_value(&report).expect("report should serialize"); let task = &json["tasks"][0]; @@ -1212,7 +1201,7 @@ mod tests { .any(|pm| pm.ecosystem() == Ecosystem::Node), "precondition: no Node PM detected" ); - let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let report = DoctorReport::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"); @@ -1242,7 +1231,7 @@ mod tests { .any(|pm| pm.ecosystem() == Ecosystem::Python), "precondition: no Python PM detected" ); - let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let report = DoctorReport::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"); @@ -1260,7 +1249,7 @@ mod tests { #[test] fn v3_report_probes_detected_pms_as_tools() { let ctx = context(vec![]); - let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let report = DoctorReport::build(&ctx, &ResolutionOverrides::default(), false); let json = serde_json::to_value(&report).expect("report should serialize"); let tool = &json["tools"][0]; diff --git a/src/schema/labels.rs b/src/schema/labels.rs index 7dc59836..28a9e545 100644 --- a/src/schema/labels.rs +++ b/src/schema/labels.rs @@ -1,31 +1,28 @@ -//! Per-version label dispatcher. +//! Source-label dispatcher. //! -//! `source_label_for(source, version)` is the only public seam between -//! [`crate::schema::project`] (which serializes the `source` field on -//! tasks and decisions) and the per-version label tables in [`super::v1`] -//! / [`super::v2`]. Adding a new schema version means: add a `vN.rs` file -//! with a `source_label` fn, add one match arm here, bump -//! [`super::CURRENT_VERSION`]. -//! -//! Versions newer than the highest-known one fall through to the latest -//! match arm — never silently misrepresent: callers must validate via -//! [`super::validate_schema_version`] *before* serializing. +//! Two surfaces disagree on one label: `doctor`/`why`'s structured +//! reports name a cargo alias task's mechanism `"cargo-alias"` (the +//! `provider` field already carries `"cargo"`), while `list`/`info`'s +//! flat shape uses plain tool names throughout. [`flat_source_label`] +//! and [`structured_source_label`] are the two call points; everything +//! else defers to [`TaskSource::label`]. use std::path::{Path, PathBuf}; use crate::types::{Task, TaskSource}; -/// Resolve the JSON `source` string for a given source + schema version. -/// -/// Validation lives in [`super::validate_schema_version`] — by the time -/// this is called the version is already proven to be in the supported -/// range. The wildcard arm picks the newest version's labels so newly -/// added versions don't need a default branch. -pub(crate) const fn source_label_for(source: TaskSource, schema_version: u32) -> &'static str { - match schema_version { - 1 => super::v1::source_label(source), - 2 => super::v2::source_label(source), - _ => super::v3::source_label(source), +/// Source label for the flat `list`/`info` shape ([`super::project`]). +pub(crate) const fn flat_source_label(source: TaskSource) -> &'static str { + source.label() +} + +/// Source label for the structured `doctor`/`why` reports. Only +/// [`TaskSource::CargoAliases`] diverges from [`flat_source_label`] — see +/// module docs. +pub(crate) const fn structured_source_label(source: TaskSource) -> &'static str { + match source { + TaskSource::CargoAliases => "cargo-alias", + _ => flat_source_label(source), } } @@ -36,11 +33,8 @@ pub(crate) const fn source_label_for(source: TaskSource, schema_version: u32) -> /// 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) - ) +pub(crate) fn fqn(source: TaskSource, name: &str) -> String { + format!("root:{kind}#{name}", kind = structured_source_label(source)) } /// Key path (structured configs) or target name (flat files) locating the diff --git a/src/schema/mod.rs b/src/schema/mod.rs index 95018bf7..bc72f800 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -1,291 +1,83 @@ -//! Versioned JSON schema for `--json` output. +//! JSON schema for `--json` output. //! //! # Layout //! -//! - [`CURRENT_VERSION`] — the latest schema this binary can produce. -//! Bump whenever any field's serialized representation changes in a -//! way clients can observe (rename, type change, removed field, etc.). -//! - [`validate_schema_version`] — gatekeeper for `--schema-version=N`; -//! rejects values outside `1..=CURRENT_VERSION` with a clean error. -//! - [`project`] — the actual JSON-shape types ([`Project`], -//! [`TaskListView`], …) and the builder switch -//! [`Project::build_with_schema`]. -//! - [`labels::source_label_for`] — the version → label-string dispatcher -//! that the project builder routes through for every `source` field. -//! - [`v1`] / [`v2`] / future [`vN`] — frozen label tables, one file per -//! schema version. Adding a new version is mechanical: copy the most -//! recent `vN.rs`, edit the strings, add one arm to -//! [`labels::source_label_for`], bump [`CURRENT_VERSION`]. +//! - [`SCHEMA_VERSION`] — the schema contract version every `--json` surface stamps into its `schema_version` field. +//! - [`validate_schema_version`] — gatekeeper for `--schema-version=N`; `clap` already bounds the flag to `1..=1`, +//! so this is a defensive second check for callers that build a version outside CLI parsing. +//! - [`project`] — the flat JSON shape ([`Project`], [`TaskListView`]) served by `list`/`info` +//! (and read internally by `doctor`'s human renderer). +//! - [`doctor`] — the structured `doctor --json` report. +//! - [`labels::flat_source_label`] / [`labels::structured_source_label`] — the two source-label conventions the shapes above use. //! -//! # When to bump the version -//! -//! Adding a field is *not* a breaking change — clients can ignore -//! unknown fields. Renaming or removing one is. The current label -//! convention (filename-style → tool names) was a rename, so it moved -//! from v1 to v2. +//! There used to be three independently-versioned schemas (`list` at v2, `doctor`/`why` at v3, with v1 the original filename-style labels). +//! Adoption never grew past internal use, so the versions were collapsed: today's shapes are the only ones, retroactively called v1. -pub(crate) mod doctor_v3; +pub(crate) mod doctor; 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 -// to crate so test files / future tooling can still reach the -// builder methods directly without going through this shim. +// Re-export so callers write `crate::schema::Project` rather than `crate::schema::project::Project`. +// The inner module stays public to crate so test files / future tooling can still reach the builder methods directly without going through this shim. pub(crate) use project::Project; -/// 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"`). -/// `"package.json"` and `"cargo"` unchanged. Consumers reading -/// `decisions.*.source` or `tasks[].source` from a v2 payload need to -/// recognize the tool-name strings. -/// -/// **v1** — original schema, filename-style source labels -/// (`"justfile"`, `"bacon.toml"`, …). Still produced when callers pass -/// `--schema-version=1`. -pub(crate) const CURRENT_VERSION: u32 = 2; - -/// 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; +/// Schema contract version every `--json` surface (`doctor`, `list`, `why`) stamps into its `schema_version` field. +/// Bump whenever any field's serialized representation changes in a way clients can observe (rename, type change, removed field, etc.). +pub(crate) const SCHEMA_VERSION: u32 = 1; -/// 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. +/// Validate that `requested` is a schema version this binary can produce. +/// Returns the version unchanged on success so callers can chain it directly into a builder. /// /// # Errors /// -/// Returns `Err` when `requested == 0` or `requested > CURRENT_VERSION`. -/// The error message advertises the supported range so client scripts -/// can adapt. +/// Returns `Err` when `requested != SCHEMA_VERSION`. `clap` already bounds `--schema-version` to `1..=1`, +/// so this only fires for callers that construct a version outside CLI parsing. pub(crate) fn validate_schema_version(requested: u32) -> anyhow::Result { - if requested == 0 || requested > CURRENT_VERSION { - anyhow::bail!( - "unsupported --schema-version {requested}; this binary speaks 1..={CURRENT_VERSION}", - ); - } - 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 { + if requested != SCHEMA_VERSION { anyhow::bail!( - "unsupported --schema-version {requested}; `runner doctor` speaks \ - 1..={DOCTOR_CURRENT_VERSION}", + "unsupported --schema-version {requested}; this binary speaks {SCHEMA_VERSION}", ); } Ok(requested) } -/// Base URL committed schemas hang off, from `[package.metadata].schema-base` -/// in `Cargo.toml` (surfaced by `build.rs`). Any trailing slash is trimmed so -/// callers append `/` uniformly. +/// Base URL committed schemas hang off, from `[package.metadata].schema-base` in `Cargo.toml` (surfaced by `build.rs`). +/// Any trailing slash is trimmed so callers append `/` uniformly. fn schemas_base_url() -> &'static str { env!("RUNNER_SCHEMA_BASE").trim_end_matches('/') } /// Canonical public URL of a committed output schema. -pub(crate) fn schema_url(command: &str, version: u32) -> String { - format!("{}/{command}.v{version}.schema.json", schemas_base_url()) +pub(crate) fn schema_url(command: &str) -> String { + format!("{}/{command}.schema.json", schemas_base_url()) } -/// Canonical URL of the `runner.toml` config schema — the committed file's -/// `$id` and the `#:schema` directive the scaffold writes. +/// Canonical URL of the `runner.toml` config schema — the committed file's `$id` and the `#:schema` directive the scaffold writes. pub(crate) fn config_schema_url() -> String { format!("{}/runner.toml.schema.json", schemas_base_url()) } -/// 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, 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] - fn every_schema_label_round_trips_through_from_label() { - // `doctor --json` / `why --json` print FQNs built from these - // labels, and `run` parses FQN input back through - // `TaskSource::from_label`. Every label of every schema version - // must round-trip, or the printed identity is unrunnable and the - // token leaks to the PM-exec fallback (bunx resolving it off the - // network) — the v3 `cargo-alias` label shipped exactly that bug. - for version in 1..=CURRENT_VERSION { - for &source in TaskSource::all() { - let label = source_label_for(source, version); - assert_eq!( - TaskSource::from_label(label), - Some(source), - "schema v{version} label {label:?} must parse back to {source:?}", - ); - } - } - } - - #[test] - fn source_label_for_returns_legacy_strings_under_v1() { - // v1 contract: filename-style labels. Frozen. - assert_eq!(source_label_for(TaskSource::Justfile, 1), "justfile"); - assert_eq!(source_label_for(TaskSource::BaconToml, 1), "bacon.toml"); - assert_eq!(source_label_for(TaskSource::MiseToml, 1), "mise.toml"); - assert_eq!(source_label_for(TaskSource::Makefile, 1), "Makefile"); - assert_eq!(source_label_for(TaskSource::TurboJson, 1), "turbo.json"); - assert_eq!(source_label_for(TaskSource::DenoJson, 1), "deno.json"); - assert_eq!(source_label_for(TaskSource::Taskfile, 1), "Taskfile"); - // Unchanged across versions: - assert_eq!(source_label_for(TaskSource::CargoAliases, 1), "cargo"); - assert_eq!(source_label_for(TaskSource::GoPackage, 1), "go"); - assert_eq!(source_label_for(TaskSource::PackageJson, 1), "package.json"); - } - - #[test] - fn source_label_for_returns_tool_names_under_v2() { - assert_eq!(source_label_for(TaskSource::Justfile, 2), "just"); - assert_eq!(source_label_for(TaskSource::BaconToml, 2), "bacon"); - assert_eq!(source_label_for(TaskSource::MiseToml, 2), "mise"); - assert_eq!(source_label_for(TaskSource::Makefile, 2), "make"); - assert_eq!(source_label_for(TaskSource::TurboJson, 2), "turbo"); - assert_eq!(source_label_for(TaskSource::DenoJson, 2), "deno"); - assert_eq!(source_label_for(TaskSource::Taskfile, 2), "task"); - assert_eq!(source_label_for(TaskSource::CargoAliases, 2), "cargo"); - assert_eq!(source_label_for(TaskSource::GoPackage, 2), "go"); - assert_eq!(source_label_for(TaskSource::PackageJson, 2), "package.json"); - } - - #[test] - fn current_version_matches_v2_labels() { - // 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), - "just" - ); - } - - #[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" - ); - } + use super::{SCHEMA_VERSION, validate_schema_version}; #[test] - fn validate_schema_version_accepts_supported_range() { + fn validate_schema_version_accepts_only_the_current_version() { assert_eq!(validate_schema_version(1).unwrap(), 1); - assert_eq!(validate_schema_version(2).unwrap(), 2); + assert_eq!(SCHEMA_VERSION, 1); } #[test] - fn validate_schema_version_rejects_zero_and_future_versions() { + fn validate_schema_version_rejects_anything_else() { let err = validate_schema_version(0).expect_err("v0 must error"); assert!(format!("{err}").contains("unsupported")); - let err = validate_schema_version(99).expect_err("future versions must error"); + let err = validate_schema_version(2).expect_err("v2 no longer exists"); let msg = format!("{err}"); assert!(msg.contains("unsupported")); assert!( - 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", + msg.contains("speaks 1"), + "error should advertise the supported version: {msg}", ); } } diff --git a/src/schema/project.rs b/src/schema/project.rs index 2579208e..df16b31d 100644 --- a/src/schema/project.rs +++ b/src/schema/project.rs @@ -1,31 +1,23 @@ -//! Typed JSON shapes for `--json` output across `doctor`, `info`, `list`, and `why`. +//! Typed JSON shapes for `--json` output across `info`, `list`, and `doctor`'s human renderer. //! -//! Every subcommand projects from the single source-of-truth [`Project`] -//! struct so the contract is defined in one place. Doctor emits the full -//! struct; info/list emit projections (currently the full shape with -//! empty task tables collapsed away by `#[serde(skip_serializing_if)]`). -//! -//! Version negotiation: [`Project::build_with_schema`] takes the requested -//! schema version and routes per-field label resolution through -//! [`super::labels::source_label_for`]. Today the *shape* of `Project` -//! is identical across v1 and v2 — only label *values* differ. If a -//! future version diverges in shape, split this struct per-version and -//! keep the builder switch in here. +//! Every one of those surfaces projects from the single source-of-truth [`Project`] struct so the +//! contract is defined in one place: `list` emits [`Project::into_list_view`], `info` emits +//! [`Project::into_info_view`], and `doctor`'s human (non-JSON) output reads this shape internally +//! even though its `--json` output is the structured [`super::doctor::DoctorReport`] instead. use std::collections::BTreeMap; use serde::Serialize; -use super::labels::source_label_for; +use super::labels::flat_source_label; use crate::resolver::{ FallbackPolicy, MismatchPolicy, OverrideOrigin, ResolutionOverrides, Resolver, }; use crate::tool::node::{ManifestSource, detect_pm_from_manifest}; use crate::types::{DetectionWarning, PackageManager, ProjectContext, TaskSource}; -/// The canonical machine-readable view of a project, used by every -/// `--json` surface. Field order is preserved by `serde_json` so -/// consumers can hand-write `jq` queries without sort surprises. +/// The canonical machine-readable view of a project, used by every `--json` surface. Field order is +/// preserved by `serde_json` so consumers can hand-write `jq` queries without sort surprises. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Project<'a> { @@ -36,8 +28,8 @@ pub(crate) struct Project<'a> { schemars(description = "URI of the JSON Schema that describes this payload.") )] pub schema: String, - /// Increments on any breaking change to this schema. Consumers - /// should reject anything they weren't built for. + /// Increments on any breaking change to this schema. + /// Consumers should reject anything they weren't built for. #[cfg_attr( feature = "schema", schemars(description = "Schema contract version for this JSON payload.") @@ -53,48 +45,32 @@ pub(crate) struct Project<'a> { pub detected: Detected<'a>, /// Effective override stack — CLI, env, and config bundled. pub overrides: OverridesView, - /// Per-ecosystem detection signals: lockfile pick, manifest - /// declaration, PATH probe results. + /// Per-ecosystem detection signals: lockfile pick, manifest declaration, PATH probe results. pub signals: Signals, /// Resolver verdict (or first-class error if the chain bailed). pub decisions: Decisions, - /// Full task list. Subcommands that don't care omit this via - /// projection. + /// Full task list. Subcommands that don't care omit this via projection. #[serde(skip_serializing_if = "Vec::is_empty")] pub tasks: Vec>, - /// Diagnostic warnings from both detection (`ctx.warnings`) and - /// the resolver (`ResolvedPm.warnings`), flattened. + /// Diagnostic warnings from both detection (`ctx.warnings`) and the resolver (`ResolvedPm.warnings`), flattened. pub warnings: Vec, } impl<'a> Project<'a> { - /// Build the full report at the latest [`super::CURRENT_VERSION`]. - /// Test-only convenience — production callers go through the - /// dispatcher, which validates `--schema-version` and always - /// passes a concrete version to [`Self::build_with_schema`]. + /// Build the full report. Test-only convenience — production callers go through the dispatcher, + /// which validates `--schema-version` and calls [`Self::build_with_schema`] directly. #[cfg(test)] pub(crate) fn build(ctx: &'a ProjectContext, overrides: &ResolutionOverrides) -> Self { - // `resolve_shims = false` keeps unit tests hermetic — no `volta - // which` spawns against the test host. - Self::build_with_schema(ctx, overrides, super::CURRENT_VERSION, false) + // `resolve_shims = false` keeps unit tests hermetic — no `volta which` spawns against the test host. + Self::build_with_schema(ctx, overrides, false) } - /// Build the report against a specific schema version. `schema_version` - /// must be a value [`super::validate_schema_version`] would accept; - /// callers validate before calling so the CLI surfaces a useful error. - /// - /// Per-field versioning: source labels route through - /// [`super::labels::source_label_for`]. PM and `TaskRunner` labels - /// are unchanged across versions. - /// - /// `resolve_shims` controls whether PATH-probe hits are classified - /// against a Volta installation (one `volta which` spawn per - /// shimmed tool). Diagnostic surfaces (`doctor`, `info --json`) - /// pass `true`; `list` passes `false` — it drops signals anyway. + /// Build the report. `resolve_shims` controls whether PATH-probe hits are classified against a + /// Volta installation (one `volta which` spawn per shimmed tool). Diagnostic surfaces + /// (`doctor`, `info --json`) pass `true`; `list` passes `false` — it drops signals anyway. pub(crate) fn build_with_schema( ctx: &'a ProjectContext, overrides: &ResolutionOverrides, - schema_version: u32, resolve_shims: bool, ) -> Self { let manifest_decl = detect_pm_from_manifest(&ctx.root); @@ -122,7 +98,7 @@ impl<'a> Project<'a> { .iter() .map(|t| TaskInfo { name: &t.name, - source: source_label_for(t.source, schema_version), + source: flat_source_label(t.source), description: t.description.as_deref(), alias_of: t.alias_of.as_deref(), passthrough_to: t.passthrough_to.map(crate::types::TaskRunner::label), @@ -133,7 +109,7 @@ impl<'a> Project<'a> { Self { schema: String::new(), - schema_version, + schema_version: super::SCHEMA_VERSION, root: ctx.root.display().to_string(), ecosystems: ctx .package_managers @@ -156,25 +132,17 @@ impl<'a> Project<'a> { } } - /// Project the full report to an `info`-shaped view: same shape - /// minus the per-task detail (which `info` doesn't need; `list` is - /// the dedicated task surface). + /// Project the full report to an `info`-shaped view: same shape minus the per-task detail + /// (which `info` doesn't need; `list` is the dedicated task surface). pub(crate) fn into_info_view(mut self) -> Self { self.tasks.clear(); self } - /// Project the full report to a `list`-shaped view: just the - /// tasks (filtered by `source` when set) plus the schema version - /// and root. Drops resolver state — `list` is purely a directory - /// listing for tasks. + /// Project the full report to a `list`-shaped view: just the tasks (filtered by `source` when set) + /// plus the schema version and root. Drops resolver state — `list` is purely a directory listing for tasks. pub(crate) fn into_list_view(self, source: Option) -> TaskListView<'a> { - // The filter compares against whichever label flavor the report - // was built with — v1 emits filename-style strings (`"justfile"`), - // v2 emits tool names (`"just"`). Using `t.source` (already - // version-resolved at build time) keeps the comparison correct - // no matter which schema the caller asked for. - let target = source.map(|s| source_label_for(s, self.schema_version)); + let target = source.map(flat_source_label); let tasks = self .tasks .into_iter() @@ -189,8 +157,7 @@ impl<'a> Project<'a> { } } -/// `list --json` projection. Same `schema_version` as [`Project`] so -/// consumers can branch on it. +/// `list --json` projection. Same `schema_version` as [`Project`] so consumers can branch on it. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct TaskListView<'a> { @@ -201,8 +168,7 @@ pub(crate) struct TaskListView<'a> { schemars(description = "URI of the JSON Schema that describes this payload.") )] pub schema: String, - /// Identical to [`Project::schema_version`]; consumers can assume - /// `1` here means a v1-shaped `tasks` array. + /// Identical to [`Project::schema_version`]; consumers can assume `1` here means a v1-shaped `tasks` array. #[cfg_attr( feature = "schema", schemars(description = "Schema contract version for this JSON payload.") @@ -214,8 +180,7 @@ pub(crate) struct TaskListView<'a> { pub tasks: Vec>, } -/// Detection results — what the file scan found, before any resolver -/// policy was applied. +/// Detection results — what the file scan found, before any resolver policy was applied. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Detected<'a> { @@ -334,9 +299,8 @@ pub(crate) struct RunnerOverrideInfo { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Signals { - /// Node-ecosystem signals. The schema is intentionally - /// node-flat today; other ecosystems get peer fields as their - /// resolver paths land. + /// Node-ecosystem signals. The schema is intentionally node-flat today; other ecosystems get + /// peer fields as their resolver paths land. pub node: NodeSignals, } @@ -350,9 +314,8 @@ pub(crate) struct NodeSignals { pub manifest_pm: Option, /// `bun`/`pnpm`/`yarn`/`npm` -> absolute path on `$PATH` (or null). pub path_probe: BTreeMap<&'static str, Option>, - /// PATH-probe hits identified as Volta shims, keyed like - /// [`Self::path_probe`]. Additive field (no schema bump): absent on - /// hosts without Volta and on surfaces that skip shim resolution. + /// PATH-probe hits identified as Volta shims, keyed like [`Self::path_probe`]. Additive field + /// (no schema bump): absent on hosts without Volta and on surfaces that skip shim resolution. #[serde(skip_serializing_if = "BTreeMap::is_empty")] pub volta_shims: BTreeMap<&'static str, VoltaShimInfo>, } @@ -361,9 +324,8 @@ pub(crate) struct NodeSignals { #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct VoltaShimInfo { - /// Real provisioned binary behind the shim; `null` when Volta has - /// no version of the tool ("not provisioned"). Shims Volta could - /// not classify at all are omitted from the map instead of guessed. + /// Real provisioned binary behind the shim; `null` when Volta has no version of the tool ("not provisioned"). + /// Shims Volta could not classify at all are omitted from the map instead of guessed. pub resolved: Option, } @@ -381,19 +343,17 @@ pub(crate) struct ManifestPm { pub on_fail: &'static str, } -/// Resolver verdict surface. Mirrors the resolver's `Result` so -/// consumers can branch on the variant before reading the inner shape. +/// Resolver verdict surface. Mirrors the resolver's `Result` so consumers can branch on the variant before reading the inner shape. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Decisions { - /// Node script-dispatch PM decision, or an error message when the - /// resolver bailed. + /// Node script-dispatch PM decision, or an error message when the resolver bailed. pub node_pm: NodePmDecision, } -/// Either a resolved Node PM or the diagnostic string for the failure -/// that prevented one. Untagged so consumers can probe via "is the -/// `pm` field present?". +/// Either a resolved Node PM or the diagnostic string for the failure that prevented one. +/// +/// Untagged so consumers can probe via "is the `pm` field present?". #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[serde(untagged)] @@ -418,8 +378,7 @@ pub(crate) enum NodePmDecision { pub(crate) struct TaskInfo<'a> { /// Task name as it appears in the config. pub name: &'a str, - /// Source label — version-resolved at build time via - /// [`super::labels::source_label_for`]. + /// Source label — resolved at build time via [`super::labels::flat_source_label`]. pub source: &'static str, /// Human-readable description, if any. #[serde(skip_serializing_if = "Option::is_none")] @@ -432,10 +391,8 @@ pub(crate) struct TaskInfo<'a> { pub passthrough_to: Option<&'static str>, } -/// Warning projected into the JSON shape. The `source`/`detail` split -/// is kept stable from the pre-A4 flat-struct days so existing -/// consumers (the `doctor` test suite, ad-hoc `jq` queries) keep -/// working. +/// Warning projected into the JSON shape. The `source`/`detail` split is kept stable from the +/// pre-A4 flat-struct days so existing consumers (the `doctor` test suite, ad-hoc `jq` queries) keep working. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct WarningInfo { @@ -507,8 +464,8 @@ const fn mismatch_label(policy: MismatchPolicy) -> &'static str { } /// Probe results for the signals section: every PATH hit, plus Volta -/// shim classification when requested. Shared with the v3 doctor -/// builder ([`super::doctor_v3`]), hence `pub(super)`. +/// shim classification when requested. Shared with the structured doctor +/// builder ([`super::doctor`]), hence `pub(super)`. pub(super) struct ProbeSignals { pub(super) path_probe: BTreeMap<&'static str, Option>, pub(super) volta_shims: BTreeMap<&'static str, VoltaShimInfo>, @@ -621,7 +578,7 @@ mod tests { let project = Project::build(&ctx, &overrides); let value = serde_json::to_value(&project).expect("Project should serialize to JSON"); - assert_eq!(value["schema_version"], 2); + assert_eq!(value["schema_version"], 1); assert_eq!(value["root"], "/tmp/test"); assert!( value["ecosystems"] @@ -675,7 +632,7 @@ mod tests { } #[test] - fn build_with_schema_serializes_v1_labels_for_tasks() { + fn build_with_schema_serializes_flat_labels_for_tasks() { let ctx = ProjectContext { root: PathBuf::from("/tmp/test"), package_managers: Vec::new(), @@ -694,14 +651,9 @@ mod tests { warnings: Vec::new(), }; - let v1 = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), 1, false); - let v1_json = serde_json::to_value(&v1).expect("v1 serialization"); - assert_eq!(v1_json["schema_version"], 1); - assert_eq!(v1_json["tasks"][0]["source"], "justfile"); - - let v2 = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), 2, false); - let v2_json = serde_json::to_value(&v2).expect("v2 serialization"); - assert_eq!(v2_json["schema_version"], 2); - assert_eq!(v2_json["tasks"][0]["source"], "just"); + let project = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&project).expect("serialization"); + assert_eq!(json["schema_version"], 1); + assert_eq!(json["tasks"][0]["source"], "just"); } } diff --git a/src/schema/v1.rs b/src/schema/v1.rs deleted file mode 100644 index fc2e3450..00000000 --- a/src/schema/v1.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! JSON schema **v1** — legacy filename-style source labels. -//! -//! Frozen contract: never edit these strings. Anything reading the JSON -//! with `--schema-version=1` was written against these exact values and -//! will break if they drift. New label conventions go in a fresh `vN.rs` -//! plus a new arm in [`super::labels::source_label_for`]. -//! -//! v1 was the original schema (issued at runner 0.10.x) and remains -//! supported indefinitely as long as the `TaskSource` enum can still -//! be mapped to these strings. When a `TaskSource` variant is *removed*, -//! the v1 mapping for it can collapse to `""` or similar — but -//! that's a v3+ design question; today every variant has a stable v1 -//! string. - -use crate::types::TaskSource; - -/// v1 source label for a given [`TaskSource`]. Mirrors the strings the -/// original `runner doctor --json` output emitted. -pub(crate) const fn source_label(source: TaskSource) -> &'static str { - match source { - TaskSource::PackageJson => "package.json", - TaskSource::Makefile => "Makefile", - TaskSource::Justfile => "justfile", - TaskSource::Taskfile => "Taskfile", - TaskSource::TurboJson => "turbo.json", - TaskSource::DenoJson => "deno.json", - TaskSource::CargoAliases => "cargo", - TaskSource::GoPackage => "go", - TaskSource::BaconToml => "bacon.toml", - TaskSource::MiseToml => "mise.toml", - TaskSource::PyprojectScripts => "pyproject.toml", - } -} diff --git a/src/schema/v2.rs b/src/schema/v2.rs deleted file mode 100644 index f8d0fad7..00000000 --- a/src/schema/v2.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! JSON schema **v2** — tool-name source labels (current default). -//! -//! v2 standardized the `source` field on tool names (`"just"`, `"bacon"`, -//! `"make"`, …) instead of the filename-style strings v1 emitted. The -//! resolved values mirror [`TaskSource::label`] one-for-one — keeping -//! them aligned is a deliberate choice: v2 IS the display convention, -//! so display and JSON agree. -//! -//! If a future v3 diverges from `TaskSource::label` (e.g. a serde-friendly -//! rename), copy this file to `v3.rs`, change the strings there, and -//! freeze v2 — *never* edit these. - -use crate::types::TaskSource; - -/// v2 source label for a given [`TaskSource`]. Defers to -/// [`TaskSource::label`] so the display column on `runner list` and the -/// JSON `source` field stay in sync; freezing v2 if v3 diverges is a -/// matter of inlining the match here. -pub(crate) const fn source_label(source: TaskSource) -> &'static str { - source.label() -} diff --git a/src/schema/v3.rs b/src/schema/v3.rs deleted file mode 100644 index 3c6bdce1..00000000 --- a/src/schema/v3.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! 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), - } -} From 8321b87d4e3cd34d54d8d6918429a1f92caa46ea Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 5 Jul 2026 03:34:24 +0200 Subject: [PATCH 2/7] fix: address #82 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - doctor(): fix stale doc comment claiming resolver errors propagate. Both the JSON path (DoctorReport::build) and the human path (Project::build_with_schema) embed the resolver Result in the report instead; the fn can only Err on JSON serialization failure. - schema.rs: PROVIDER_LABELS was a hand-maintained copy of cmd::why::provider_label, free to drift like the pre-#77 source_labels() array did. Derive it from provider_label instead (now pub(super)); add in-memory + committed-schema drift guard tests mirroring the existing TaskSourceLabel ones. - why.rs: the "filtered" decision reason always blamed "--runner/RUNNER_RUNNER restrictions", but a --runner/[task_runner].prefer restriction that empties the eligible set always errors out earlier via runner_constraint_error — the only reachable path to "filtered" is a qualifier (deno:x) matching no candidate's source. Thread the qualifier through build_report/decision_report so the reason names it correctly instead of blaming an override that was never set. Not fixed: the review also suggested build_report/decision_report should receive the runner/qualifier-restricted candidate set instead of the unfiltered one. Verified this would be wrong — lookup_token deliberately returns same-named tasks from other sources on a qualifier miss so why can explain *why* nothing was selected; narrowing report.candidates would hide that and collapse the "filtered" strategy into indistinguishable "exec-fallback". Fixed the actual bug (the reason text) instead. --- src/cmd/doctor.rs | 9 ++++-- src/cmd/schema.rs | 55 +++++++++++++++++++++++++++++++---- src/cmd/why.rs | 73 +++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index caef707f..9eeaaf19 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -24,8 +24,13 @@ use crate::types::ProjectContext; /// /// # Errors /// -/// Propagates `Resolver::resolve_node_pm` errors when the configured fallback policy is `error` and -/// nothing is on `$PATH`. Always succeeds for the `probe`/`npm` fallback policies on real systems. +/// A `Resolver::resolve_node_pm` failure (e.g. `--fallback error` with +/// nothing on `$PATH`) is embedded in the report rather than propagated: +/// the JSON path serializes `DoctorReport::build`, which keeps the +/// resolver's `Result` as part of the report, and the human path builds +/// `Project` the same way. This can only return `Err` when JSON +/// serialization itself fails, which does not happen for these types in +/// practice. pub(crate) fn doctor( ctx: &ProjectContext, overrides: &ResolutionOverrides, diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index 346bf721..0f2f5511 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -212,7 +212,7 @@ fn patch_why_task(defs: &mut Map) { } defs.insert( "ProviderLabel".to_string(), - json!({ "type": "string", "enum": PROVIDER_LABELS }), + json!({ "type": "string", "enum": provider_labels() }), ); patch_def_field(defs, "WhyTask", "kind", "TaskSourceLabel"); patch_def_field(defs, "WhyTask", "provider", "ProviderLabel"); @@ -240,10 +240,15 @@ fn task_source_label_schema(command: &str) -> Value { } /// Closed set for the `why` `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", -]; +/// executes the task. Derived from [`crate::types::TaskSource::all`] +/// through [`super::why::provider_label`], the same function `why` calls +/// at runtime, so the committed schema's enum can't drift from it. +fn provider_labels() -> Vec<&'static str> { + crate::types::TaskSource::all() + .iter() + .map(|&source| super::why::provider_label(source)) + .collect() +} /// Closed label set for `command`'s source labels, derived from /// [`crate::types::TaskSource::all`] through the same label functions @@ -424,4 +429,44 @@ mod tests { ); } } + + #[test] + fn provider_label_schema_matches_runtime_labels() { + // provider_labels() used to be a hand-maintained PROVIDER_LABELS + // array, free to drift from cmd::why::provider_label. Now that + // it's derived, this test is a tautology against today's + // implementation — its job is to catch a future regression back + // to a hardcoded list. + let enum_values = super::provider_labels(); + let runtime_values: Vec<&str> = crate::types::TaskSource::all() + .iter() + .map(|&source| super::super::why::provider_label(source)) + .collect(); + assert_eq!( + enum_values, runtime_values, + "ProviderLabel enum must match cmd::why::provider_label exactly" + ); + } + + #[test] + fn committed_why_schema_provider_label_matches_runtime_labels() { + // Mirrors committed_schemas_task_source_label_matches_runtime_labels: + // proves the committed schemas/why.schema.json wasn't left stale + // after a generator fix. + let raw = std::fs::read_to_string("schemas/why.schema.json") + .expect("committed why schema should be readable"); + let schema: Value = serde_json::from_str(&raw).expect("schema should parse as JSON"); + let enum_values: Vec<&str> = schema["$defs"]["ProviderLabel"]["enum"] + .as_array() + .expect("expected $defs.ProviderLabel.enum array") + .iter() + .map(|v| v.as_str().expect("enum values should be strings")) + .collect(); + assert_eq!( + enum_values, + super::provider_labels(), + "schemas/why.schema.json: committed ProviderLabel enum has drifted from \ + cmd::why::provider_label — run `just gen-schema` and commit the result" + ); + } } diff --git a/src/cmd/why.rs b/src/cmd/why.rs index 057bb20d..1a5855ca 100644 --- a/src/cmd/why.rs +++ b/src/cmd/why.rs @@ -81,6 +81,7 @@ pub(crate) fn why( pm_decision.as_ref(), overrides, ctx, + qualifier, ); println!("{}", serde_json::to_string_pretty(&report)?); } else { @@ -327,6 +328,7 @@ fn build_report<'a>( pm_decision: Option<&PmDecision>, overrides: &ResolutionOverrides, ctx: &'a ProjectContext, + qualifier: Option, ) -> WhyReport<'a> { let candidate_report = |task: &'a Task| WhyCandidate { task: task_report(task, ctx, pm_decision, selected), @@ -341,7 +343,7 @@ fn build_report<'a>( pm_resolution: pm_decision.map(pm_resolution), selected: selected.map(candidate_report), candidates: candidates.iter().copied().map(candidate_report).collect(), - decision: decision_report(candidates, selected), + decision: decision_report(candidates, selected, qualifier), } } @@ -395,7 +397,11 @@ fn match_report<'a>( } } -fn decision_report(candidates: &[&Task], selected: Option<&Task>) -> WhyDecision { +fn decision_report( + candidates: &[&Task], + selected: Option<&Task>, + qualifier: Option, +) -> WhyDecision { if candidates.is_empty() { return WhyDecision { strategy: "exec-fallback", @@ -405,10 +411,27 @@ fn decision_report(candidates: &[&Task], selected: Option<&Task>) -> WhyDecision }; } if selected.is_none() { + // A --runner/[task_runner].prefer restriction that empties the + // eligible set errors out in `why()` before this function is ever + // called (`runner_constraint_error`); the only way to reach this + // branch with a non-empty `candidates` is a qualifier (`deno:x`) + // that doesn't match any candidate's source. + let reason = qualifier.map_or_else( + || { + "every candidate was filtered out by --runner/RUNNER_RUNNER restrictions" + .to_string() + }, + |source| { + format!( + "candidates exist for this name, but none are registered under the `{}:` \ + qualifier", + source.label() + ) + }, + ); return WhyDecision { strategy: "filtered", - reason: "every candidate was filtered out by --runner/RUNNER_RUNNER restrictions" - .to_string(), + reason, }; } if candidates.len() == 1 { @@ -429,7 +452,7 @@ fn decision_report(candidates: &[&Task], selected: Option<&Task>) -> WhyDecision /// Tool family that executes tasks from this source. Distinct from the /// structured `kind` label, which names the extraction mechanism. -const fn provider_label(source: TaskSource) -> &'static str { +pub(super) const fn provider_label(source: TaskSource) -> &'static str { match source { TaskSource::PackageJson => "node", TaskSource::DenoJson => "deno", @@ -657,6 +680,7 @@ mod tests { None, &ResolutionOverrides::default(), &ctx, + None, ); let json = serde_json::to_value(&report).expect("report should serialize"); @@ -698,6 +722,7 @@ mod tests { None, &ResolutionOverrides::default(), &ctx, + None, ); let json = serde_json::to_value(&report).expect("report should serialize"); @@ -706,6 +731,41 @@ mod tests { assert_eq!(json["decision"]["strategy"], "exec-fallback"); } + #[test] + fn report_describes_qualifier_mismatch_as_filtered_not_runner_restricted() { + // `why deno:build` when "build" exists elsewhere but not under + // deno.json: `candidates` still lists the same-named tasks + // (useful diagnostic — `lookup_token` surfaces them precisely so + // this case is explainable), but nothing is eligible under the + // `deno:` qualifier, so `selected` is None. The "filtered" reason + // must name the qualifier, not blame a --runner restriction that + // was never set. + let ctx = context(vec![ + task("build", TaskSource::PackageJson), + task("build", TaskSource::Justfile), + ]); + let candidates: Vec<&Task> = ctx.tasks.iter().collect(); + + let report = build_report( + "deno:build", + &candidates, + None, + None, + &ResolutionOverrides::default(), + &ctx, + Some(TaskSource::DenoJson), + ); + let json = serde_json::to_value(&report).expect("report should serialize"); + + assert_eq!(json["selected"], serde_json::Value::Null); + assert_eq!(json["candidates"].as_array().map(Vec::len), Some(2)); + assert_eq!(json["decision"]["strategy"], "filtered"); + assert_eq!( + json["decision"]["reason"], + "candidates exist for this name, but none are registered under the `deno:` qualifier" + ); + } + #[test] fn report_ranks_multiple_candidates() { let ctx = context(vec![ @@ -720,6 +780,7 @@ mod tests { None, &ResolutionOverrides::default(), &ctx, + None, ); let json = serde_json::to_value(&report).expect("report should serialize"); @@ -750,6 +811,7 @@ mod tests { Some(&pm_decision), &ResolutionOverrides::default(), &ctx, + None, ); let json = serde_json::to_value(&report).expect("report should serialize"); @@ -775,6 +837,7 @@ mod tests { None, &ResolutionOverrides::default(), &ctx, + None, ); let json = serde_json::to_value(&report).expect("report should serialize"); From f53500a2350933bb42bdb8e91df7d67777726481 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 5 Jul 2026 04:15:12 +0200 Subject: [PATCH 3/7] feat(config): generate the init-template scaffold from real code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not just field-name coverage — the previous drift guard (#77) only checked that every RunnerConfig field's NAME appeared somewhere in the hand-typed INIT_TEMPLATE string. The accepted VALUES shown (which PMs, which policy labels) were still copy-pasted prose that could drift from what the resolver actually parses. - INIT_TEMPLATE is now include_str!'d from schemas/runner.init.toml, generated by cmd::schema::render_init_template() from RunnerConfig's schemars metadata: section order/descriptions come straight from the section structs' doc comments (RunnerConfig field order reshuffled to match the desired scaffold reading order — cosmetic, serde matches by name not position). - FIELD_TEMPLATE supplies the one thing schemars can't (which value to show commented-out) via a FieldHint enum: Static (booleans, open-ended arrays/maps), ClosedSet/Annotated (small closed vocabularies, hint generated from the real accepted-value set, not hand-typed). - accepted_labels()/broader_vocab() are the real source of truth per field: PackageManager::all() filtered by ecosystem for pm.node/ pm.python, TaskRunner::all() for task_runner.prefer, FallbackPolicy::ALL/MismatchPolicy::ALL/ScriptPolicy::SETTABLE for resolution.fallback/on_mismatch/install.scripts, task_source_labels() for tasks.prefer/overrides, PackageManager::all() (unfiltered) for install.pms. - FallbackPolicy/MismatchPolicy/ScriptPolicy gained real label()/ALL (SETTABLE for ScriptPolicy, since Default has no user-facing label) methods in resolver/types.rs. Their parse functions (resolver/policies.rs, resolver/overrides.rs) and the two display call sites (schema/project.rs, schema/doctor.rs) now delegate to them instead of four separately hardcoded copies of the same label strings. - Every FIELD_TEMPLATE example value is validated (parsed as TOML, every string leaf checked) against the real accepted/broader vocabulary — an example using a value the resolver would actually reject is now a build-time panic, not a silent lie in the scaffold. - Sabotage-verified: a variant added to a policy enum without an Annotated entry, and an example value outside the real accepted set, both fail loudly. --- CHANGELOG.md | 14 ++ schemas/runner.init.toml | 72 +++++++ schemas/runner.toml.schema.json | 6 +- src/cmd/schema.rs | 372 ++++++++++++++++++++++++++++++++ src/config.rs | 95 ++------ src/resolver/overrides.rs | 23 +- src/resolver/policies.rs | 34 +-- src/resolver/types.rs | 53 +++++ src/schema/doctor.rs | 16 +- src/schema/project.rs | 24 +-- 10 files changed, 572 insertions(+), 137 deletions(-) create mode 100644 schemas/runner.init.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 54035b4d..d2eb240f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,20 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic structured report (previously reachable via `--schema-version 3`); the flat v1/v2 shape is gone from both. `--schema-version` now only accepts `1`; `2`/`3` are rejected. +- `runner config init`'s scaffold is now generated from `RunnerConfig`'s + schemars metadata instead of hand-typed: section headers and their + leading comments come straight from the section structs' doc comments, + and every enum-valued field's inline hint (`pm.node`, `pm.python`, + `resolution.fallback`, `resolution.on_mismatch`, `install.scripts`, + `task_runner.prefer`) is generated from the same types the resolver + parses those values with, not hand-typed prose. A config field, or an + accepted value for one of these, can no longer ship without scaffold + coverage — drift-guard tests fail the build instead. A few section + descriptions read slightly differently as a result. `FallbackPolicy`, + `MismatchPolicy`, and `ScriptPolicy` gained real `label()`/`ALL` (or + `SETTABLE`) methods, replacing four separate hardcoded copies of their + accepted strings (parse function, two display call sites, and now the + scaffold) with one. ### Removed diff --git a/schemas/runner.init.toml b/schemas/runner.init.toml new file mode 100644 index 00000000..7eaf7495 --- /dev/null +++ b/schemas/runner.init.toml @@ -0,0 +1,72 @@ +# runner.toml — project task-runner configuration. +# Docs: https://runner.kjanat.dev +# +# Every key below is commented out, showing either its built-in default or an +# illustrative example value. Uncomment and edit the ones you want to pin. +# Precedence, highest first: +# CLI flags > RUNNER_* env vars > this file > manifest declarations. + +# `[pm]` section — per-ecosystem package manager overrides. +[pm] +# node = "pnpm" # npm | yarn | pnpm | bun | deno +# python = "uv" # uv | poetry | pipenv + +# `[tasks]` section — persistent task-source preference for ambiguous task +# names (a name that exists under more than one source, e.g. a `package.json` +# script *and* a `turbo` task). +# +# Both knobs speak the same label vocabulary: a label is a task runner +# (`turbo`, `make`, …), a package manager (`bun`, `npm`, `pnpm`, `yarn`, +# `deno`, …), or a source name (`package.json`, `deno`, …). Package-manager +# labels map to the script source they run (`bun` → `package.json`). +# Selection here is **rank-only**: it never hard-rejects an unlisted source, +# it only reorders. An explicit CLI qualifier (`package.json:test`), +# `--runner`, or `--pm`/`RUNNER_PM` still outranks these file settings. +[tasks] +# prefer = ["turbo", "bun"] # global order: turbo, then package.json (bun) +# overrides = { dev = "bun", build = "turbo" } # per-task pins beat the order + +# `[task_runner]` section — **deprecated**. Use `[tasks]` instead. +# +# Kept for backward compatibility: existing `[task_runner].prefer` files +# keep working (and emit a deprecation warning), but `[tasks].prefer` is the +# supported successor — rank-only and able to name package managers, not just +# task runners. +[task_runner] +# prefer = ["just", "turbo"] # turbo | nx | make | just | task | mise | bacon + +# `[install]` section — restrict which detected package managers +# `runner install` runs with. Absent or empty installs every detected +# PM (the default). Overridden by `RUNNER_INSTALL_PMS`. +# +# Unlike `[pm]` (which scopes *script dispatch* per ecosystem), this +# scopes the *install fan-out*: in a polyglot repo where both `bun` and +# `deno` would write `node_modules`, `pms = ["bun"]` keeps install to bun. +[install] +# pms = ["bun"] # only install with these; each must be detected +# scripts = "deny" # deny | allow (absent = each PM's own default) + +# `[resolution]` section — resolver policy knobs. +[resolution] +# fallback = "probe" # probe (PATH probe) | npm (legacy) | error +# on_mismatch = "warn" # warn | ignore | error (exit 2) + +# `[chain]` section — failure policy for `run -s/-p` chains and +# `runner install `. +[chain] +# keep_going = false # run every task despite failures (same as -k) +# kill_on_fail = false # parallel: kill siblings on first failure (same as -K) + +# `[github]` section — GitHub Actions integration. Both knobs only take +# effect under GitHub Actions (gated at the call site by +# `actions_rs::env::is_github_actions`); in a normal terminal nothing here +# changes behavior. +[github] +# group_output = true # wrap each task's output in a collapsible ::group:: +# group_parallel = true # buffer parallel tasks, print each as one block + +# `[parallel]` section — how parallel (`-p`) chains present their output +# **outside** GitHub Actions. (Under GitHub Actions, see +# `[github].group_parallel` instead.) +[parallel] +# grouped = false # buffer + print each task as one block on completion diff --git a/schemas/runner.toml.schema.json b/schemas/runner.toml.schema.json index 96bce65e..a16607d2 100644 --- a/schemas/runner.toml.schema.json +++ b/schemas/runner.toml.schema.json @@ -3,7 +3,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$defs": { "ChainSection": { - "description": "`[chain]` section — failure policy for `run -s/-p` chains and\n`runner install `.\n\n`Option` rather than `bool` so the resolver can distinguish\n\"user explicitly set false\" from \"user didn't say\": env-overrides-\nconfig layering means `[chain].keep_going = false` plus\n`RUNNER_KEEP_GOING=1` resolves to `true`.", + "description": "`[chain]` section — failure policy for `run -s/-p` chains and\n`runner install `.", "type": "object", "properties": { "keep_going": { @@ -81,7 +81,7 @@ "additionalProperties": false }, "ParallelSection": { - "description": "`[parallel]` section — how parallel (`-p`) chains present their output\n**outside** GitHub Actions. (Under GitHub Actions, see\n[`GitHubSection::group_parallel`].)", + "description": "`[parallel]` section — how parallel (`-p`) chains present their output\n**outside** GitHub Actions. (Under GitHub Actions, see\n`[github].group_parallel` instead.)", "type": "object", "properties": { "grouped": { @@ -161,7 +161,7 @@ "additionalProperties": false }, "TaskRunnerSection": { - "description": "`[task_runner]` section — **deprecated**. Use [`TasksSection`] (`[tasks]`)\ninstead.\n\nKept for backward compatibility: existing `[task_runner].prefer` files\nkeep working (and emit a deprecation warning), but `[tasks].prefer` is the\nsupported successor — rank-only and able to name package managers, not just\ntask runners.", + "description": "`[task_runner]` section — **deprecated**. Use `[tasks]` instead.\n\nKept for backward compatibility: existing `[task_runner].prefer` files\nkeep working (and emit a deprecation warning), but `[tasks].prefer` is the\nsupported successor — rank-only and able to name package managers, not just\ntask runners.", "deprecated": true, "type": "object", "properties": { diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index 0f2f5511..627b7bf4 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -1,5 +1,6 @@ //! `runner schema` — emit committed JSON Schemas (feature `schema`). +use std::fmt::Write as _; use std::io::Write as _; use std::path::Path; @@ -85,9 +86,368 @@ fn write_all_schemas(dir: &Path) -> Result<()> { write_json(Some(&dir.join(document.filename)), &document.value)?; } + let init_template_path = dir.join("runner.init.toml"); + std::fs::write(&init_template_path, render_init_template()) + .with_context(|| format!("failed to write {}", init_template_path.display()))?; + Ok(()) } +/// How a [`FIELD_TEMPLATE`] entry's inline hint is produced. +#[derive(Clone, Copy)] +enum FieldHint { + /// Hand-written hint text — for booleans and fields whose accepted + /// values aren't a small fixed set ([`broader_vocab`] validates + /// their example value instead of enumerating every label inline). + Static(&'static str), + /// The field's real accepted-value set ([`accepted_labels`]), + /// pipe-joined bare, with an optional trailing suffix note. + ClosedSet { suffix: Option<&'static str> }, + /// The field's real accepted-value set, each with a short + /// parenthetical note. Every label [`accepted_labels`] returns for + /// this field must have exactly one entry here — enforced by + /// `field_template_hints_cover_every_accepted_label`. + Annotated(&'static [(&'static str, &'static str)]), +} + +/// (section, field) -> (commented-out value, hint). Every field +/// [`crate::config::RunnerConfig`]'s schemars metadata declares must +/// have an entry here, and every entry must name a real field — both +/// enforced by [`render_init_template`]'s own assertions, which run +/// whenever `committed_init_template_matches_generator` exercises it — +/// so a new config field can't ship without scaffold coverage. Values +/// are either the field's real built-in default (`fallback`, +/// `on_mismatch`, the three booleans) or, where there's no single +/// sensible default to show (an unset PM override, an empty preference +/// list), a hand-picked illustrative example — validated against the +/// real accepted vocabulary (`accepted_labels`/`broader_vocab`) by +/// `field_template_values_use_real_accepted_labels`. +const FIELD_TEMPLATE: &[(&str, &str, &str, FieldHint)] = &[ + ( + "pm", + "node", + r#""pnpm""#, + FieldHint::ClosedSet { suffix: None }, + ), + ( + "pm", + "python", + r#""uv""#, + FieldHint::ClosedSet { suffix: None }, + ), + ( + "tasks", + "prefer", + r#"["turbo", "bun"]"#, + FieldHint::Static("global order: turbo, then package.json (bun)"), + ), + ( + "tasks", + "overrides", + r#"{ dev = "bun", build = "turbo" }"#, + FieldHint::Static("per-task pins beat the order"), + ), + ( + "task_runner", + "prefer", + r#"["just", "turbo"]"#, + FieldHint::ClosedSet { suffix: None }, + ), + ( + "install", + "pms", + r#"["bun"]"#, + FieldHint::Static("only install with these; each must be detected"), + ), + ( + "install", + "scripts", + r#""deny""#, + FieldHint::ClosedSet { + suffix: Some("(absent = each PM's own default)"), + }, + ), + ( + "resolution", + "fallback", + r#""probe""#, + FieldHint::Annotated(&[("probe", "PATH probe"), ("npm", "legacy"), ("error", "")]), + ), + ( + "resolution", + "on_mismatch", + r#""warn""#, + FieldHint::Annotated(&[("warn", ""), ("ignore", ""), ("error", "exit 2")]), + ), + ( + "chain", + "keep_going", + "false", + FieldHint::Static("run every task despite failures (same as -k)"), + ), + ( + "chain", + "kill_on_fail", + "false", + FieldHint::Static("parallel: kill siblings on first failure (same as -K)"), + ), + ( + "github", + "group_output", + "true", + FieldHint::Static("wrap each task's output in a collapsible ::group::"), + ), + ( + "github", + "group_parallel", + "true", + FieldHint::Static("buffer parallel tasks, print each as one block"), + ), + ( + "parallel", + "grouped", + "false", + FieldHint::Static("buffer + print each task as one block on completion"), + ), +]; + +/// Render a [`FieldHint`] into the trailing `# ...` comment text (without +/// the leading `#`), or `None` for no hint. +fn render_hint(section: &str, field: &str, hint: &FieldHint) -> String { + match hint { + FieldHint::Static(text) => (*text).to_string(), + FieldHint::ClosedSet { suffix } => { + let labels = accepted_labels(section, field).unwrap_or_else(|| { + panic!("{section}.{field}: ClosedSet needs an accepted_labels entry") + }); + let joined = labels.join(" | "); + suffix.map_or_else(|| joined.clone(), |suffix| format!("{joined} {suffix}")) + } + FieldHint::Annotated(notes) => { + let labels = accepted_labels(section, field).unwrap_or_else(|| { + panic!("{section}.{field}: Annotated needs an accepted_labels entry") + }); + let annotated: Vec<&str> = notes.iter().map(|(label, _)| *label).collect(); + assert!( + annotated == labels, + "{section}.{field}: Annotated labels {annotated:?} don't match the real accepted \ + set {labels:?} exactly (wrong order, or a variant was added/removed without \ + updating the annotation table)" + ); + notes + .iter() + .map(|(label, note)| { + if note.is_empty() { + (*label).to_string() + } else { + format!("{label} ({note})") + } + }) + .collect::>() + .join(" | ") + } + } +} + +/// The real, closed accepted-value set for a config field with a small +/// fixed vocabulary — derived from the same types/functions the resolver +/// uses to parse that field, so it cannot drift from what's actually +/// accepted. `None` for booleans and fields with no single fixed set +/// (see [`broader_vocab`] for those with a large-but-real vocabulary). +fn accepted_labels(section: &str, field: &str) -> Option> { + use crate::resolver::{FallbackPolicy, MismatchPolicy, ScriptPolicy}; + use crate::types::{Ecosystem, PackageManager, TaskRunner}; + + match (section, field) { + ("pm", "node") => Some( + PackageManager::all() + .iter() + .filter(|pm| matches!(pm.ecosystem(), Ecosystem::Node | Ecosystem::Deno)) + .map(|pm| pm.label()) + .collect(), + ), + ("pm", "python") => Some( + PackageManager::all() + .iter() + .filter(|pm| pm.ecosystem() == Ecosystem::Python) + .map(|pm| pm.label()) + .collect(), + ), + ("task_runner", "prefer") => Some(TaskRunner::all().iter().map(|r| r.label()).collect()), + ("install", "scripts") => Some( + ScriptPolicy::SETTABLE + .iter() + .filter_map(|p| p.label()) + .collect(), + ), + ("resolution", "fallback") => Some(FallbackPolicy::ALL.iter().map(|p| p.label()).collect()), + ("resolution", "on_mismatch") => { + Some(MismatchPolicy::ALL.iter().map(|p| p.label()).collect()) + } + _ => None, + } +} + +/// The real accepted-value vocabulary for a config field whose set is +/// too large to enumerate as an inline hint (so [`FIELD_TEMPLATE`] keeps +/// a hand-written [`FieldHint::Static`] hint for it), but whose example +/// *value* should still be checked against something real rather than +/// trusted blind. `None` for fields with neither a closed nor a broader +/// vocabulary to check against (plain booleans). +fn broader_vocab(section: &str, field: &str) -> Option> { + match (section, field) { + ("install", "pms") => Some( + crate::types::PackageManager::all() + .iter() + .map(|pm| pm.label()) + .collect(), + ), + ("tasks", "prefer" | "overrides") => Some(crate::types::task_source_labels()), + _ => None, + } +} + +/// Assert every string leaf in a [`FIELD_TEMPLATE`] `value` literal is a +/// real accepted value for `section.field` — [`accepted_labels`] when the +/// field has a closed vocabulary, else [`broader_vocab`], else no check +/// (plain booleans have neither). `value` parses directly as a bare TOML +/// value expression (scalar, array, or inline table) — the same syntax +/// it's spliced into after `field = ` in the real scaffold. +fn assert_value_uses_real_labels(section: &str, field: &str, value: &str) { + let Some(vocab) = accepted_labels(section, field).or_else(|| broader_vocab(section, field)) + else { + return; + }; + + let parsed: toml::Value = value.parse().unwrap_or_else(|err| { + panic!("{section}.{field}: value {value:?} is not valid TOML: {err}") + }); + let mut leaves = Vec::new(); + collect_string_leaves(&parsed, &mut leaves); + + for leaf in leaves { + assert!( + vocab.contains(&leaf.as_str()), + "{section}.{field}: example value {value:?} uses {leaf:?}, which isn't in the real \ + accepted set {vocab:?}" + ); + } +} + +fn collect_string_leaves(value: &toml::Value, out: &mut Vec) { + match value { + toml::Value::String(s) => out.push(s.clone()), + toml::Value::Array(items) => items.iter().for_each(|v| collect_string_leaves(v, out)), + toml::Value::Table(map) => map.values().for_each(|v| collect_string_leaves(v, out)), + _ => {} + } +} + +const INIT_TEMPLATE_HEADER: &str = r"# runner.toml — project task-runner configuration. +# Docs: https://runner.kjanat.dev +# +# Every key below is commented out, showing either its built-in default or an +# illustrative example value. Uncomment and edit the ones you want to pin. +# Precedence, highest first: +# CLI flags > RUNNER_* env vars > this file > manifest declarations. +"; + +/// Render the `runner.toml` scaffold `runner config init` writes. +/// +/// Walks [`crate::config::RunnerConfig`]'s schemars metadata — section +/// order and doc-comment descriptions come straight from the struct, so +/// a field can't be silently forgotten or its prose silently drift from +/// the type. [`FIELD_TEMPLATE`] supplies the one thing schemars can't: +/// which value to show commented-out. +/// +/// # Panics +/// +/// Panics if `RunnerConfig`'s schema is malformed (a property without a +/// `$defs` `$ref`) or a schema field has no [`FIELD_TEMPLATE`] entry — +/// both indicate a real bug the generator should surface loudly, not +/// paper over, since this only ever runs under `just gen-schema`. +pub(crate) fn render_init_template() -> String { + let schema = serde_json::to_value(schemars::schema_for!(crate::config::RunnerConfig)) + .expect("RunnerConfig schema should serialize"); + let top_properties = schema["properties"] + .as_object() + .expect("RunnerConfig schema must have top-level properties"); + let defs = schema["$defs"] + .as_object() + .expect("RunnerConfig schema must have $defs"); + + let mut out = INIT_TEMPLATE_HEADER.to_string(); + let mut used = std::collections::HashSet::with_capacity(FIELD_TEMPLATE.len()); + for (section, section_schema) in top_properties { + let def_name = section_schema["$ref"] + .as_str() + .and_then(|r| r.strip_prefix("#/$defs/")) + .unwrap_or_else(|| panic!("{section}: expected a $defs $ref in the schema")); + let def = &defs[def_name]; + let description = def["description"].as_str().unwrap_or_default(); + + out.push('\n'); + for line in description.lines() { + if line.is_empty() { + out.push_str("#\n"); + } else { + out.push_str("# "); + out.push_str(&strip_intra_doc_links(line)); + out.push('\n'); + } + } + let _ = writeln!(out, "[{section}]"); + + let properties = def["properties"] + .as_object() + .unwrap_or_else(|| panic!("{def_name}: expected a properties object")); + for field in properties.keys() { + let &(entry_section, entry_field, value, hint) = FIELD_TEMPLATE + .iter() + .find(|(s, f, ..)| s == section && f == field) + .unwrap_or_else(|| panic!("{section}.{field}: missing FIELD_TEMPLATE entry")); + used.insert((entry_section, entry_field)); + assert_value_uses_real_labels(section, field, value); + let hint = render_hint(section, field, &hint); + let _ = writeln!(out, "# {field} = {value} # {hint}"); + } + } + + let orphaned: Vec = FIELD_TEMPLATE + .iter() + .filter(|&&(s, f, ..)| !used.contains(&(s, f))) + .map(|(s, f, ..)| format!("{s}.{f}")) + .collect(); + assert!( + orphaned.is_empty(), + "FIELD_TEMPLATE has entries for fields RunnerConfig no longer declares: {orphaned:?} — \ + remove them" + ); + + out +} + +/// Rewrite a rustdoc intra-doc link (`` [`Type::field`] ``) into plain +/// backticked text (`` `Type::field` ``) — the square brackets signal a +/// hyperlink to rustdoc/schemars consumers, but read as stray punctuation +/// in a plain-text scaffold comment. +fn strip_intra_doc_links(line: &str) -> String { + let mut out = String::with_capacity(line.len()); + let mut rest = line; + while let Some(start) = rest.find("[`") { + out.push_str(&rest[..start]); + let after_bracket = &rest[start + 1..]; + let Some(end) = after_bracket.find("`]") else { + out.push_str(&rest[start..]); + return out; + }; + out.push_str(&after_bracket[..=end]); + rest = &after_bracket[end + 2..]; + } + out.push_str(rest); + out +} + fn schema_documents() -> Result> { Ok(vec![ SchemaDocument { @@ -469,4 +829,16 @@ mod tests { cmd::why::provider_label — run `just gen-schema` and commit the result" ); } + + #[test] + fn committed_init_template_matches_generator() { + let generated = super::render_init_template(); + let committed = std::fs::read_to_string("schemas/runner.init.toml") + .expect("committed init template should be readable"); + assert_eq!( + generated, committed, + "schemas/runner.init.toml has drifted from render_init_template() — run `just \ + gen-schema` and commit the result" + ); + } } diff --git a/src/config.rs b/src/config.rs index ca2d8265..e5588764 100644 --- a/src/config.rs +++ b/src/config.rs @@ -49,69 +49,14 @@ pub(crate) const CONFIG_FILENAME: &str = "runner.toml"; /// precedence first: the directory itself (`""`) and its `.config/` subdir. pub(crate) const CONFIG_DIRS: [&str; 2] = ["", ".config"]; -/// Starter `runner.toml` scaffolded by `runner config init`. Every knob is -/// present, set to its built-in default, and commented out — uncommenting a -/// line is the only edit needed to override it. Keep in sync with the -/// section structs below; `config init` is the most discoverable docs we -/// ship, so a missing knob here is effectively an undocumented feature. -pub(crate) const INIT_TEMPLATE: &str = r#"# runner.toml — project task-runner configuration. -# Docs: https://runner.kjanat.dev -# -# Every key below shows its built-in default and is commented out. Uncomment -# and edit the ones you want to pin. Precedence, highest first: -# CLI flags > RUNNER_* env vars > this file > manifest declarations. - -# Force the package manager per ecosystem, overriding lockfile detection. -[pm] -# node = "pnpm" # npm | pnpm | yarn | bun | deno -# python = "uv" # uv | poetry | pipenv - -# Persistent preference for which source runs an ambiguous task name (a name -# that exists under more than one source — e.g. a package.json script AND a -# turbo task). Labels are runner names, package-manager names (bun, npm, ...), -# or source names (package.json). Rank-only: unlisted sources still run. -[tasks] -# prefer = ["turbo", "bun"] # global order: turbo, then package.json (bun) -# overrides = { dev = "bun", build = "turbo" } # per-task pins beat the order - -# Deprecated — use [tasks] above. Legacy ranked allow-list of task runners that -# also *restricts* candidates (a same-named task under an unlisted runner is -# rejected). Still honored for existing configs; prints a deprecation warning. -[task_runner] -# prefer = ["just", "turbo"] # turbo, nx, make, just, task, mise, bacon - -# Restrict which detected package managers `runner install` runs. Empty/absent -# installs every detected PM. Overridden by RUNNER_INSTALL_PMS (comma-separated). -# `scripts` controls install-time lifecycle scripts: "deny" skips them where the -# PM allows it (npm/yarn/pnpm/bun/composer; deno already denies); "allow" forces -# them on where the PM can express it (npm/yarn-berry/deno). bun and pnpm (>=10) -# deny fine but can't force scripts on: their dependency build scripts are gated -# by a manifest allowlist runner won't touch, so only "allow" warns there. -# Overridden by RUNNER_INSTALL_SCRIPTS, then the --no-scripts / --scripts flags. -[install] -# pms = ["bun"] # only install with these; each must be detected -# scripts = "deny" # deny | allow (absent = each PM's own default) - -# Resolver policy knobs. -[resolution] -# fallback = "probe" # probe (PATH probe) | npm (legacy) | error -# on_mismatch = "warn" # warn | error (exit 2) | ignore (manifest vs lockfile) - -# Failure policy for `-s`/`-p` task chains and `install `. -# keep_going and kill_on_fail are mutually exclusive — setting both is an error. -[chain] -# keep_going = false # run every task despite failures (same as -k) -# kill_on_fail = false # parallel: kill siblings on first failure (same as -K) - -# GitHub Actions output grouping. Both keys take effect only under Actions. -[github] -# group_output = true # wrap each task's output in a collapsible ::group:: -# group_parallel = true # buffer parallel tasks, print each as one block - -# Parallel (`-p`) output presentation outside GitHub Actions. -[parallel] -# grouped = false # buffer + print each task as one block on completion -"#; +/// Starter `runner.toml` scaffolded by `runner config init`. Generated from +/// [`RunnerConfig`]'s schemars metadata (section/field doc comments) plus a +/// small hand-picked value/hint table — see +/// `cmd::schema::render_init_template` — so a field can't silently ship +/// without scaffold coverage. Regenerate with `just gen-schema` after +/// changing a section struct; a drift-guard test enforces this file stays +/// in sync. +pub(crate) const INIT_TEMPLATE: &str = include_str!("../schemas/runner.init.toml"); /// Parsed `runner.toml` content plus the absolute path it was loaded from. #[derive(Debug, Clone)] @@ -138,13 +83,16 @@ pub(crate) struct RunnerConfig { /// `[pm]` — per-ecosystem package-manager overrides. #[serde(default)] pub pm: PmSection, + /// `[tasks]` — persistent task-source preference (global order + per-task pins). + #[serde(default)] + pub tasks: TasksSection, /// `[task_runner]` — task-runner preferences. Deprecated; superseded /// by [`Self::tasks`]. #[serde(default, rename = "task_runner")] pub task_runner: TaskRunnerSection, - /// `[tasks]` — persistent task-source preference (global order + per-task pins). + /// `[install]` — restrict which detected PMs `runner install` runs. #[serde(default)] - pub tasks: TasksSection, + pub install: InstallSection, /// `[resolution]` — resolver-policy knobs. #[serde(default)] pub resolution: ResolutionSection, @@ -157,9 +105,6 @@ pub(crate) struct RunnerConfig { /// `[parallel]` — presentation of parallel (`-p`) chain output. #[serde(default)] pub parallel: ParallelSection, - /// `[install]` — restrict which detected PMs `runner install` runs. - #[serde(default)] - pub install: InstallSection, } /// `[install]` section — restrict which detected package managers @@ -205,11 +150,10 @@ pub(crate) struct InstallSection { /// `[chain]` section — failure policy for `run -s/-p` chains and /// `runner install `. -/// -/// `Option` rather than `bool` so the resolver can distinguish -/// "user explicitly set false" from "user didn't say": env-overrides- -/// config layering means `[chain].keep_going = false` plus -/// `RUNNER_KEEP_GOING=1` resolves to `true`. +// Fields are `Option` rather than `bool` so the resolver can +// distinguish "user explicitly set false" from "user didn't say": +// env-overrides-config layering means `[chain].keep_going = false` plus +// `RUNNER_KEEP_GOING=1` resolves to `true`. #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[cfg_attr( feature = "schema", @@ -292,7 +236,7 @@ const fn default_github_group_parallel() -> bool { /// `[parallel]` section — how parallel (`-p`) chains present their output /// **outside** GitHub Actions. (Under GitHub Actions, see -/// [`GitHubSection::group_parallel`].) +/// `[github].group_parallel` instead.) #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[cfg_attr( feature = "schema", @@ -335,8 +279,7 @@ pub(crate) struct PmSection { pub python: Option, } -/// `[task_runner]` section — **deprecated**. Use [`TasksSection`] (`[tasks]`) -/// instead. +/// `[task_runner]` section — **deprecated**. Use `[tasks]` instead. /// /// Kept for backward compatibility: existing `[task_runner].prefer` files /// keep working (and emit a deprecation warning), but `[tasks].prefer` is the diff --git a/src/resolver/overrides.rs b/src/resolver/overrides.rs index 3f182347..f9fc8da7 100644 --- a/src/resolver/overrides.rs +++ b/src/resolver/overrides.rs @@ -362,14 +362,21 @@ fn parse_install_scripts(sources: &OverrideSources<'_>) -> Result /// Returns an error naming the (sanitized) value when it is neither `deny` /// nor `allow`. fn parse_script_policy_label(raw: &str) -> Result { - match raw.trim() { - "deny" => Ok(ScriptPolicy::Deny), - "allow" => Ok(ScriptPolicy::Allow), - _ => Err(anyhow!( - "unknown script policy \"{}\"; expected \"deny\" or \"allow\"", - sanitize_raw_label(raw), - )), - } + let trimmed = raw.trim(); + ScriptPolicy::SETTABLE + .into_iter() + .find(|policy| policy.label() == Some(trimmed)) + .ok_or_else(|| { + anyhow!( + "unknown script policy \"{}\"; expected \"{}\"", + sanitize_raw_label(raw), + ScriptPolicy::SETTABLE + .iter() + .filter_map(|p| p.label()) + .collect::>() + .join("\" or \""), + ) + }) } /// Validate a loaded `runner.toml` in isolation — no CLI or environment diff --git a/src/resolver/policies.rs b/src/resolver/policies.rs index 315ce6ac..569538e0 100644 --- a/src/resolver/policies.rs +++ b/src/resolver/policies.rs @@ -39,14 +39,15 @@ pub(super) const ENV_BOOL_FALSY: &[&str] = &["0", "false", "no", "off"]; pub(super) const ENV_BOOL_TRUTHY: &[&str] = &["1", "true", "yes", "on"]; pub(super) fn parse_fallback_label(raw: &str) -> Result { - match raw { - "probe" => Ok(FallbackPolicy::Probe), - "npm" => Ok(FallbackPolicy::Npm), - "error" => Ok(FallbackPolicy::Error), - other => Err(anyhow!( - "unknown fallback policy {other:?}; expected one of probe, npm, error", - )), - } + FallbackPolicy::ALL + .into_iter() + .find(|policy| policy.label() == raw) + .ok_or_else(|| { + anyhow!( + "unknown fallback policy {raw:?}; expected one of {}", + join_labels(FallbackPolicy::ALL.iter().map(|p| p.label())), + ) + }) } pub(super) fn resolve_fallback_policy( @@ -201,14 +202,15 @@ pub(super) fn parse_tasks_overrides( } pub(super) fn parse_mismatch_label(raw: &str) -> Result { - match raw { - "warn" => Ok(MismatchPolicy::Warn), - "error" => Ok(MismatchPolicy::Error), - "ignore" => Ok(MismatchPolicy::Ignore), - other => Err(anyhow!( - "unknown on-mismatch policy {other:?}; expected one of warn, error, ignore", - )), - } + MismatchPolicy::ALL + .into_iter() + .find(|policy| policy.label() == raw) + .ok_or_else(|| { + anyhow!( + "unknown on-mismatch policy {raw:?}; expected one of {}", + join_labels(MismatchPolicy::ALL.iter().map(|p| p.label())), + ) + }) } pub(super) fn resolve_mismatch_policy( diff --git a/src/resolver/types.rs b/src/resolver/types.rs index 4a415226..43b59f26 100644 --- a/src/resolver/types.rs +++ b/src/resolver/types.rs @@ -139,6 +139,23 @@ pub(crate) enum FallbackPolicy { Error, } +impl FallbackPolicy { + /// Every variant, in the order [`Self::label`]'s callers should list + /// them. Single source of truth for + /// [`super::policies::parse_fallback_label`] and any surface that + /// needs to advertise or validate against the same closed set. + pub(crate) const ALL: [Self; 3] = [Self::Probe, Self::Npm, Self::Error]; + + /// The `--fallback` / `RUNNER_FALLBACK` / `[resolution].fallback` label. + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Probe => "probe", + Self::Npm => "npm", + Self::Error => "error", + } + } +} + /// Install-time lifecycle-script execution policy for `runner install`. /// /// Lifecycle/build scripts (`postinstall`, native-extension compilation, @@ -175,6 +192,24 @@ pub(crate) enum ScriptPolicy { Allow, } +impl ScriptPolicy { + /// The two labels a user can actually type — `Default` is the + /// internal "unset" sentinel, never a valid `[install].scripts` / + /// `RUNNER_INSTALL_SCRIPTS` value. Single source of truth for + /// [`super::overrides::parse_script_policy_label`]. + pub(crate) const SETTABLE: [Self; 2] = [Self::Deny, Self::Allow]; + + /// The user-facing label, or `None` for [`Self::Default`] (never + /// user-settable — see [`Self::SETTABLE`]). + pub(crate) const fn label(self) -> Option<&'static str> { + match self { + Self::Default => None, + Self::Deny => Some("deny"), + Self::Allow => Some("allow"), + } + } +} + /// How to react when manifest declaration (step 5) and lockfile (step 6) /// disagree about which package manager the project uses. /// @@ -197,6 +232,24 @@ pub(crate) enum MismatchPolicy { Error, } +impl MismatchPolicy { + /// Every variant, in the order [`Self::label`]'s callers should list + /// them. Single source of truth for + /// [`super::policies::parse_mismatch_label`] and any surface that + /// needs to advertise or validate against the same closed set. + pub(crate) const ALL: [Self; 3] = [Self::Warn, Self::Ignore, Self::Error]; + + /// The `--on-mismatch` / `RUNNER_ON_MISMATCH` / `[resolution].on_mismatch` + /// label. + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Warn => "warn", + Self::Ignore => "ignore", + Self::Error => "error", + } + } +} + /// A package-manager override plus the source the user set it from. #[derive(Debug, Clone)] pub(crate) struct PmOverride { diff --git a/src/schema/doctor.rs b/src/schema/doctor.rs index 340fa181..17f59132 100644 --- a/src/schema/doctor.rs +++ b/src/schema/doctor.rs @@ -33,9 +33,7 @@ use serde::Serialize; use super::labels::structured_source_label; use crate::cmd::run::{resolve_python_pm, select_task_entry, source_depth, source_priority}; -use crate::resolver::{ - FallbackPolicy, MismatchPolicy, ResolutionOverrides, ResolutionStep, Resolver, -}; +use crate::resolver::{ResolutionOverrides, ResolutionStep, Resolver}; use crate::tool::node::detect_pm_from_manifest; use crate::types::{DetectionWarning, Ecosystem, PackageManager, ProjectContext, Task, TaskSource}; @@ -498,18 +496,10 @@ fn runner_info() -> RunnerInfo { fn overrides_report(overrides: &ResolutionOverrides) -> Overrides { Overrides { explain: overrides.explain, - fallback: match overrides.fallback { - FallbackPolicy::Probe => "probe", - FallbackPolicy::Npm => "npm", - FallbackPolicy::Error => "error", - }, + fallback: overrides.fallback.label(), no_warnings: overrides.no_warnings, quiet: overrides.quiet, - on_mismatch: match overrides.on_mismatch { - MismatchPolicy::Warn => "warn", - MismatchPolicy::Error => "error", - MismatchPolicy::Ignore => "ignore", - }, + on_mismatch: overrides.on_mismatch.label(), pm: overrides.pm.as_ref().map(|o| o.pm.label()), pm_by_ecosystem: overrides .pm_by_ecosystem diff --git a/src/schema/project.rs b/src/schema/project.rs index df16b31d..7fb6d10f 100644 --- a/src/schema/project.rs +++ b/src/schema/project.rs @@ -10,9 +10,7 @@ use std::collections::BTreeMap; use serde::Serialize; use super::labels::flat_source_label; -use crate::resolver::{ - FallbackPolicy, MismatchPolicy, OverrideOrigin, ResolutionOverrides, Resolver, -}; +use crate::resolver::{OverrideOrigin, ResolutionOverrides, Resolver}; use crate::tool::node::{ManifestSource, detect_pm_from_manifest}; use crate::types::{DetectionWarning, PackageManager, ProjectContext, TaskSource}; @@ -267,8 +265,8 @@ impl OverridesView { origin: origin_label(&o.origin), }), prefer_runners: overrides.prefer_runners.iter().map(|r| r.label()).collect(), - fallback: fallback_label(overrides.fallback), - on_mismatch: mismatch_label(overrides.on_mismatch), + fallback: overrides.fallback.label(), + on_mismatch: overrides.on_mismatch.label(), explain: overrides.explain, no_warnings: overrides.no_warnings, } @@ -447,22 +445,6 @@ fn origin_label(origin: &OverrideOrigin) -> String { } } -const fn fallback_label(policy: FallbackPolicy) -> &'static str { - match policy { - FallbackPolicy::Probe => "probe", - FallbackPolicy::Npm => "npm", - FallbackPolicy::Error => "error", - } -} - -const fn mismatch_label(policy: MismatchPolicy) -> &'static str { - match policy { - MismatchPolicy::Warn => "warn", - MismatchPolicy::Error => "error", - MismatchPolicy::Ignore => "ignore", - } -} - /// Probe results for the signals section: every PATH hit, plus Volta /// shim classification when requested. Shared with the structured doctor /// builder ([`super::doctor`]), hence `pub(super)`. From 0416ce30908e7306d07fe64253b621653d8b4cee Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 5 Jul 2026 04:58:55 +0200 Subject: [PATCH 4/7] fix(schema): convert init-template generator panic to a clean CLI error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render_init_template() panics by design on FIELD_TEMPLATE/RunnerConfig drift (a hard failure is right for the drift-guard test that normally catches this pre-merge) — but write_all_schemas called it unconditionally, so a released binary hitting that drift would crash `runner schema --all` with a raw, unhandled panic instead of a normal error exit. checked_init_template() wraps the call in catch_unwind with the default panic hook suppressed (no backtrace noise), converting a caught panic into an anyhow::Error with the same message. Sabotage-verified: an out-of-vocabulary FIELD_TEMPLATE value now exits 1 with a clean 'Error: ...' line instead of a panic backtrace. Not fixed here: the same review also flagged doctor.rs's Overrides struct missing prefer_sources/task_source_overrides/failure_policy/ group_output/github_group_parallel/parallel_grouped/install_pms/ script_policy/parent_group_open. That's real on this branch alone, but it's exactly issue #81's scope, already fully implemented (all 9 of those fields, plus a structural drift-guard test) on the stacked branch issue-81-overrides-fields (PR #83, based on this one) — duplicating it here would conflict when #83 rebases after this merges. --- CHANGELOG.md | 7 +++++++ src/cmd/schema.rs | 31 ++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2eb240f..3269dd94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,13 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic suffix (`doctor.v3.schema.json` → `doctor.schema.json`, etc.); the 10 superseded schema/example files are deleted. +### Fixed + +- `runner schema --all` no longer surfaces a raw Rust panic if the + init-template generator ever drifts from `RunnerConfig` in a released + binary (the drift-guard test should already catch this before merge); + it now reports a clean CLI error instead. + ## [0.18.1] - 2026-07-04 ### Fixed diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index 627b7bf4..6b7922db 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -87,12 +87,41 @@ fn write_all_schemas(dir: &Path) -> Result<()> { } let init_template_path = dir.join("runner.init.toml"); - std::fs::write(&init_template_path, render_init_template()) + std::fs::write(&init_template_path, checked_init_template()?) .with_context(|| format!("failed to write {}", init_template_path.display()))?; Ok(()) } +/// [`render_init_template`], but converted into a clean [`anyhow::Error`] +/// instead of an unhandled panic reaching `runner schema --all`'s caller. +/// `render_init_template` panics on `FIELD_TEMPLATE`/`RunnerConfig` drift +/// by design (a hard, loud failure is exactly right for the drift-guard +/// test that normally catches this before merge) — this is only the +/// production CLI path's translation of that same failure into a +/// `Result`, with the default panic hook suppressed so users see one +/// clean error instead of a raw backtrace followed by one. +fn checked_init_template() -> Result { + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let result = std::panic::catch_unwind(render_init_template); + std::panic::set_hook(previous_hook); + + result.map_err(|payload| { + let message = payload + .downcast_ref::<&str>() + .map(|s| (*s).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| { + "render_init_template panicked with a non-string payload".to_string() + }); + anyhow::anyhow!( + "internal error generating the runner.toml scaffold (FIELD_TEMPLATE has drifted from \ + RunnerConfig): {message}" + ) + }) +} + /// How a [`FIELD_TEMPLATE`] entry's inline hint is produced. #[derive(Clone, Copy)] enum FieldHint { From f7b001c25581027890a5c4d74be563c147209afe Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 5 Jul 2026 05:00:03 +0200 Subject: [PATCH 5/7] chore(cargo): add dwn alias + nightly rustdoc lint config --- .cargo/config.toml | 1 + .cargo/nightly.toml | 8 ++++++++ .zed/settings.json | 16 ++++++++-------- 3 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 .cargo/nightly.toml diff --git a/.cargo/config.toml b/.cargo/config.toml index 82357807..70f354bf 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -11,6 +11,7 @@ t = "test --all-features --all-targets" bb = "build --bin run --bin runner" bbr = "bb --release" cl = "clippy --all-targets --all-features" +dwn = "--config .cargo/nightly.toml doc --workspace --all-features" # rustup run nightly cargo dwn rq = "run --quiet" rr = "run --release" diff --git a/.cargo/nightly.toml b/.cargo/nightly.toml new file mode 100644 index 00000000..ae4f7846 --- /dev/null +++ b/.cargo/nightly.toml @@ -0,0 +1,8 @@ +[build] # rustup run nightly -- cargo dwn +rustflags = ["-D", "warnings"] +rustdocflags = [ + "-Zcrate-attr=feature(rustdoc_missing_doc_code_examples)", + "-Zcrate-attr=warn(rustdoc::missing_doc_code_examples)", + "-D", + "warnings", +] diff --git a/.zed/settings.json b/.zed/settings.json index 2dfe0508..767493e6 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -50,13 +50,13 @@ }, }, }, - "rust-analyzer": { - "initialization_options": { - "diagnostics": { "experimental": { "enable": true } }, - "check": { "allTargets": true, "features": "all", "workspace": true }, - "cargo": { "allTargets": true, "features": ["all"] }, - }, - }, + // "rust-analyzer": { + // "initialization_options": { + // "diagnostics": { "experimental": { "enable": true } }, + // "check": { "allTargets": true, "features": "all", "workspace": true }, + // "cargo": { "allTargets": true, "features": ["all"] }, + // }, + // }, "cargo-tom": { "initialization_options": { "per_page": 25, // Search @@ -68,5 +68,5 @@ }, }, "file_types": { "SVG": ["*.ico"], "Shell Script": [".runner-dev"] }, - "languages": { "TOML": { "language_servers": ["tombi", "..."] } }, + "languages": { "TOML": { "language_servers": ["tombi", "..."] }, "Rust": { "wrap_guides": [100] } }, } From 7e210c70b367ef38159e384798cbb6356ac31b0c Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 5 Jul 2026 05:21:05 +0200 Subject: [PATCH 6/7] refactor(schema): extract panic_message helper; fix stale zed schema paths checked_init_template's downcast_ref chain now lives in a standalone panic_message() fn, unit-tested for &str/String/fallback payloads. .zed/settings.json: doctor/list/why schema mappings pointed at deleted v1/v2/v3 files (collapsed to unversioned in 2d397fe). Point at the current files, drop the dead entries. --- .zed/settings.json | 32 ++++++-------------------------- src/cmd/schema.rs | 42 +++++++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index 767493e6..7c055822 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -15,36 +15,16 @@ "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.example.json", "doctor.example.json"], + "url": "./schemas/doctor.schema.json", }, { - "fileMatch": ["schemas/doctor.v2.example.json", "doctor.v2.example.json"], - "url": "./schemas/doctor.v2.schema.json", + "fileMatch": ["schemas/list.example.json", "list.example.json"], + "url": "./schemas/list.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", + "fileMatch": ["schemas/why.example.json", "why.example.json"], + "url": "./schemas/why.schema.json", }, ], }, diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index 6b7922db..35332f3f 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -108,13 +108,7 @@ fn checked_init_template() -> Result { std::panic::set_hook(previous_hook); result.map_err(|payload| { - let message = payload - .downcast_ref::<&str>() - .map(|s| (*s).to_string()) - .or_else(|| payload.downcast_ref::().cloned()) - .unwrap_or_else(|| { - "render_init_template panicked with a non-string payload".to_string() - }); + let message = panic_message(&*payload); anyhow::anyhow!( "internal error generating the runner.toml scaffold (FIELD_TEMPLATE has drifted from \ RunnerConfig): {message}" @@ -122,6 +116,19 @@ fn checked_init_template() -> Result { }) } +/// Extracts a human-readable message from a caught panic payload, covering +/// the two payload shapes `panic!`/`assert!` actually produce (`&str` for +/// string literals, `String` for `format!`-built messages) and falling back +/// to a fixed message for anything else (e.g. a payload built from +/// `panic_any` with a non-string type). +fn panic_message(payload: &(dyn std::any::Any + Send)) -> String { + payload + .downcast_ref::<&str>() + .map(|s| (*s).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "render_init_template panicked with a non-string payload".to_string()) +} + /// How a [`FIELD_TEMPLATE`] entry's inline hint is produced. #[derive(Clone, Copy)] enum FieldHint { @@ -870,4 +877,25 @@ mod tests { gen-schema` and commit the result" ); } + + #[test] + fn panic_message_extracts_str_payload() { + let payload: Box = Box::new("boom"); + assert_eq!(super::panic_message(&*payload), "boom"); + } + + #[test] + fn panic_message_extracts_string_payload() { + let payload: Box = Box::new(String::from("boom")); + assert_eq!(super::panic_message(&*payload), "boom"); + } + + #[test] + fn panic_message_falls_back_for_non_string_payload() { + let payload: Box = Box::new(42_i32); + assert_eq!( + super::panic_message(&*payload), + "render_init_template panicked with a non-string payload" + ); + } } From 38f967f42c36b97a68874d1308a3c85538ef9ed4 Mon Sep 17 00:00:00 2001 From: Kaj Kowalski Date: Sun, 5 Jul 2026 07:45:16 +0200 Subject: [PATCH 7/7] fix(schema): drop deprecated task_runner from init scaffold, dedupe schema pragma render_init_template() now skips printing sections schemars marks deprecated (task_runner) entirely, instead of emitting an active empty table new users would never want. FIELD_TEMPLATE validation still runs against it, just not the printing. schemas/runner.init.toml keeps its own repo-relative #:schema pragma (tombi.toml now associates it via [[schemas]].include); cmd::config::init() strips that pragma and substitutes the real published URL instead of stacking both when writing a user's runner.toml. KNOWN_SCHEMA/INIT_TEMPLATE drift-guard test updated: deprecated sections are recognized by KNOWN_SCHEMA (back-compat parsing) without being in the scaffold. --- schemas/runner.init.toml | 11 ++------- src/cmd/config.rs | 49 ++++++++++++++++++++++++++++++++++------ src/cmd/schema.rs | 28 +++++++++++++++++++---- src/config.rs | 30 ++++++++++++++++-------- tombi.toml | 2 +- 5 files changed, 90 insertions(+), 30 deletions(-) diff --git a/schemas/runner.init.toml b/schemas/runner.init.toml index 7eaf7495..edb9d47f 100644 --- a/schemas/runner.init.toml +++ b/schemas/runner.init.toml @@ -1,3 +1,5 @@ +#:schema ./runner.toml.schema.json + # runner.toml — project task-runner configuration. # Docs: https://runner.kjanat.dev # @@ -26,15 +28,6 @@ # prefer = ["turbo", "bun"] # global order: turbo, then package.json (bun) # overrides = { dev = "bun", build = "turbo" } # per-task pins beat the order -# `[task_runner]` section — **deprecated**. Use `[tasks]` instead. -# -# Kept for backward compatibility: existing `[task_runner].prefer` files -# keep working (and emit a deprecation warning), but `[tasks].prefer` is the -# supported successor — rank-only and able to name package managers, not just -# task runners. -[task_runner] -# prefer = ["just", "turbo"] # turbo | nx | make | just | task | mise | bacon - # `[install]` section — restrict which detected package managers # `runner install` runs with. Absent or empty installs every detected # PM (the default). Overridden by `RUNNER_INSTALL_PMS`. diff --git a/src/cmd/config.rs b/src/cmd/config.rs index 7ffeb926..56cec261 100644 --- a/src/cmd/config.rs +++ b/src/cmd/config.rs @@ -37,19 +37,31 @@ fn init(dir: &Path, force: bool) -> Result { ); return Ok(2); } - // Line 1 is a `#:schema` directive (derived from the crate's repository) - // so editors with a TOML language server get autocompletion with no setup. - let contents = format!( - "#:schema {}\n{}", - crate::schema::config_schema_url(), - config::INIT_TEMPLATE, - ); + // config::INIT_TEMPLATE carries its own repo-relative `#:schema` pragma + // (for editing this repo's copy) — swap it for the real published URL + // rather than stacking a second pragma line in the user's project. + let body = strip_leading_schema_pragma(config::INIT_TEMPLATE); + let contents = format!("#:schema {}\n\n{body}", crate::schema::config_schema_url()); fs::write(&target, contents) .with_context(|| format!("failed to write {}", target.display()))?; println!("{} {}", "wrote".green().bold(), target.display()); Ok(0) } +/// Strips a leading `#:schema ...` pragma line (plus one following blank +/// line) from `template`, or returns it unchanged if it doesn't start with +/// one. Not tied to the exact pragma text, so it keeps working if the +/// repo-relative path in `schemas/runner.init.toml` ever changes. +fn strip_leading_schema_pragma(template: &str) -> &str { + let Some(rest) = template.strip_prefix("#:schema ") else { + return template; + }; + let Some((_, after_line)) = rest.split_once('\n') else { + return template; + }; + after_line.strip_prefix('\n').unwrap_or(after_line) +} + /// `runner config show` — render the effective config (file values merged /// with built-in defaults) as TOML, or JSON with `--json`. Propagates parse /// errors; use `validate` for a non-fatal diagnostic. @@ -152,6 +164,14 @@ mod tests { && first.ends_with("schemas/runner.toml.schema.json"), "line 1 must be the schema directive, got: {first:?}" ); + // INIT_TEMPLATE carries its own repo-relative `#:schema` pragma (for + // editing schemas/runner.init.toml in this repo); it must be swapped + // for the absolute one above, not stacked alongside it. + assert_eq!( + written.matches("#:schema").count(), + 1, + "exactly one #:schema directive, got:\n{written}" + ); // The scaffold must itself be valid (the directive is a comment, and // everything else is commented out → an empty, all-defaults config). assert_eq!( @@ -161,6 +181,21 @@ mod tests { ); } + #[test] + fn strip_leading_schema_pragma_removes_pragma_and_blank_line() { + let template = "#:schema ./runner.toml.schema.json\n\n# runner.toml\n[pm]\n"; + assert_eq!( + super::strip_leading_schema_pragma(template), + "# runner.toml\n[pm]\n" + ); + } + + #[test] + fn strip_leading_schema_pragma_leaves_non_pragma_content_untouched() { + let template = "# runner.toml\n[pm]\n"; + assert_eq!(super::strip_leading_schema_pragma(template), template); + } + #[test] fn init_refuses_existing_without_force() { let dir = TempDir::new("config-init-existing"); diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index 35332f3f..c098ab1c 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -379,7 +379,9 @@ fn collect_string_leaves(value: &toml::Value, out: &mut Vec) { } } -const INIT_TEMPLATE_HEADER: &str = r"# runner.toml — project task-runner configuration. +const INIT_TEMPLATE_HEADER: &str = r"#:schema ./runner.toml.schema.json + +# runner.toml — project task-runner configuration. # Docs: https://runner.kjanat.dev # # Every key below is commented out, showing either its built-in default or an @@ -420,6 +422,27 @@ pub(crate) fn render_init_template() -> String { .and_then(|r| r.strip_prefix("#/$defs/")) .unwrap_or_else(|| panic!("{section}: expected a $defs $ref in the schema")); let def = &defs[def_name]; + let properties = def["properties"] + .as_object() + .unwrap_or_else(|| panic!("{def_name}: expected a properties object")); + + if def["deprecated"].as_bool().unwrap_or(false) { + // Deprecated sections (e.g. `task_runner`, superseded by `tasks`) + // still need their FIELD_TEMPLATE entries validated so drift is + // caught, but new users shouldn't be handed a deprecated section + // in their starter file — so skip printing it entirely. + for field in properties.keys() { + let &(entry_section, entry_field, value, hint) = FIELD_TEMPLATE + .iter() + .find(|(s, f, ..)| s == section && f == field) + .unwrap_or_else(|| panic!("{section}.{field}: missing FIELD_TEMPLATE entry")); + used.insert((entry_section, entry_field)); + assert_value_uses_real_labels(section, field, value); + let _ = render_hint(section, field, &hint); + } + continue; + } + let description = def["description"].as_str().unwrap_or_default(); out.push('\n'); @@ -434,9 +457,6 @@ pub(crate) fn render_init_template() -> String { } let _ = writeln!(out, "[{section}]"); - let properties = def["properties"] - .as_object() - .unwrap_or_else(|| panic!("{def_name}: expected a properties object")); for field in properties.keys() { let &(entry_section, entry_field, value, hint) = FIELD_TEMPLATE .iter() diff --git a/src/config.rs b/src/config.rs index e5588764..305008b1 100644 --- a/src/config.rs +++ b/src/config.rs @@ -805,14 +805,22 @@ mod tests { #[test] fn known_schema_matches_init_template_sections_and_fields() { // Guard KNOWN_SCHEMA against drift in both directions, at section AND - // field granularity. The scaffold ships every knob (commented out), so - // its sections/fields are the canonical set; a field missing from - // KNOWN_SCHEMA makes `config init` write a file that warns about its - // own keys, while a stale KNOWN_SCHEMA entry lists a field nobody can - // set. Equality catches either, so adding a struct field forces the - // template and KNOWN_SCHEMA to be updated alongside it. + // field granularity. The scaffold ships every non-deprecated knob + // (commented out), so its sections/fields are the canonical set + // modulo deprecated sections (see DEPRECATED_SECTIONS below), which + // `render_init_template` deliberately omits so new users never get + // handed one; a field missing from KNOWN_SCHEMA makes `config init` + // write a file that warns about its own keys, while a stale + // KNOWN_SCHEMA entry lists a field nobody can set. Equality catches + // either, so adding a struct field forces the template and + // KNOWN_SCHEMA to be updated alongside it. use std::collections::{BTreeMap, BTreeSet}; + // Sections KNOWN_SCHEMA recognizes (for backward-compat parsing) but + // that `render_init_template` intentionally leaves out of the + // scaffold because they're deprecated. + const DEPRECATED_SECTIONS: &[&str] = &["task_runner"]; + // Walk the template into section -> {field names it emits}. let mut template: BTreeMap> = BTreeMap::new(); let mut section: Option = None; @@ -844,7 +852,7 @@ mod tests { } } - let known: BTreeMap> = KNOWN_SCHEMA + let mut known: BTreeMap> = KNOWN_SCHEMA .iter() .map(|(name, fields)| { ( @@ -853,11 +861,15 @@ mod tests { ) }) .collect(); + for section in DEPRECATED_SECTIONS { + known.remove(*section); + } assert_eq!( template, known, - "INIT_TEMPLATE sections/fields must match KNOWN_SCHEMA exactly — keep the section \ - structs, the scaffold template, and KNOWN_SCHEMA in sync when adding a knob" + "INIT_TEMPLATE sections/fields must match KNOWN_SCHEMA (minus DEPRECATED_SECTIONS) \ + exactly — keep the section structs, the scaffold template, and KNOWN_SCHEMA in sync \ + when adding a knob" ); } diff --git a/tombi.toml b/tombi.toml index eb688f0c..e9b18a86 100644 --- a/tombi.toml +++ b/tombi.toml @@ -50,4 +50,4 @@ strict = true [[schemas]] path = "./schemas/runner.toml.schema.json" -include = ["runner.toml"] +include = ["runner.toml", "schemas/runner.init.toml"]