diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index dd99152..319400c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,7 +7,7 @@ jobs: runs-on: ubuntu-slim environment: { name: github-pages, url: "${{ steps.deployment.outputs.page_url }}" } steps: [ - { uses: actions/checkout@v6, with: { sparse-checkout: schemas/*.schema.json } }, + { uses: actions/checkout@v6, with: { sparse-checkout: schemas/*.schema.json, sparse-checkout-cone-mode: false } }, { uses: actions/configure-pages@v5, with: { enablement: true }, id: conf }, { uses: actions/upload-pages-artifact@v4, with: { path: . } }, { uses: actions/deploy-pages@v4, id: deployment }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 400d3d2..498d8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,41 @@ The format is based on [Keep a Changelog], and this project adheres to [Semantic - [ ] Update the `[Unreleased]` compare link to the new tag. - [ ] Create and push a signed `vX.Y.Z` tag from `master`. +### Added + +- `runner doctor --json` schema **v3** (now the default for `doctor`): + the flat detection dump becomes a structured diagnostic inventory — + `invocation`/`environment`/`runner` provenance, per-`ecosystems` + decisions with a `confidence` grade derived from the resolution step + (override/manifest/lockfile → high, PATH probe → medium, legacy npm + fallback → low, failure → none), task `sources` as first-class objects, + `fqn`-keyed `tasks` with effective `resolved` commands, PATH-probed + `tools`, duplicate-task-name `conflicts` (which task wins, which are + shadowed, and why), flattened `diagnostics`, and a self-describing + `resolution` policy block. Implements the former `doctor.v3-draft` + schema; the real output validates against both the committed + `doctor.v3.schema.json` and the original draft. Draft shapes nothing + can emit yet (rich dependency edges, workspace identity, probe errors) + are deferred, not declared. v1/v2 remain available via + `--schema-version`; human output is unchanged. +- `runner why --json` schema **v3** (now the default for `why`): the + report is restructured around `{task, match}` candidate pairs plus a + `decision` block. Each task carries a stable identity + (`fqn` = `root::`, `provider`, `kind` — cargo aliases are + now labeled `cargo-alias`), its origin (`source` file, + `source_pointer` key path), and resolution data (`definition`, + `resolved` command preview, `cwd`, sibling `aliases`, + `dependencies`). The `match` half exposes the exact run-time selection + key (`source_priority`, `depth`, `display_order`, alias-last), and + `decision.strategy` names the branch taken (`single-candidate`, + `ranked`, `filtered`, `exec-fallback`). Implements the former + `why.v3-draft` example, which the real output now reproduces verbatim; + v1/v2 stay available via `--schema-version`. `doctor` and `list` + remain at v2 — their v3 drafts are still under review, and they reject + `--schema-version 3` rather than mislabel output. + `schema --all` emits the committed `schemas/why.v3.schema.json`, and + the example validates against it. + ## [0.13.0] - 2026-06-12 ### Added diff --git a/schemas/doctor.v1.example.json b/schemas/doctor.v1.example.json index 68e8a8a..17a3909 100644 --- a/schemas/doctor.v1.example.json +++ b/schemas/doctor.v1.example.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "root": "/home/kjanat/projects/runner", + "root": "/path/to/project", "ecosystems": [ "node", "rust" @@ -14,7 +14,7 @@ "just" ], "node_version": null, - "current_node": "24.14.1", + "current_node": "24.0.0", "monorepo": true }, "overrides": { @@ -33,24 +33,21 @@ "manifest_pm": { "pm": "bun", "source": "packageManager", - "version": "1.3.14", + "version": "1.0.0", "on_fail": "ignore" }, "path_probe": { - "bun": "/home/kjanat/.bun/bin/bun", - "npm": "/home/kjanat/.volta/bin/npm", - "pnpm": "/home/kjanat/.volta/bin/pnpm", - "yarn": "/home/kjanat/.volta/bin/yarn" + "bun": "/usr/local/bin/bun", + "npm": "/opt/volta/bin/npm", + "pnpm": null, + "yarn": "/opt/volta/bin/yarn" }, "volta_shims": { "npm": { - "resolved": "/home/kjanat/.volta/tools/image/npm/11.6.2/bin/npm" - }, - "pnpm": { - "resolved": "/home/kjanat/.volta/tools/image/packages/pnpm/bin/pnpm" + "resolved": "/opt/volta/tools/image/npm/10.0.0/bin/npm" }, "yarn": { - "resolved": "/home/kjanat/.volta/tools/image/yarn/1.22.22/bin/yarn" + "resolved": null } } } diff --git a/schemas/doctor.v2.example.json b/schemas/doctor.v2.example.json index 0c283e8..952f581 100644 --- a/schemas/doctor.v2.example.json +++ b/schemas/doctor.v2.example.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "root": "/home/kjanat/projects/runner", + "root": "/path/to/project", "ecosystems": [ "node", "rust" @@ -14,7 +14,7 @@ "just" ], "node_version": null, - "current_node": "24.14.1", + "current_node": "24.0.0", "monorepo": true }, "overrides": { @@ -33,24 +33,21 @@ "manifest_pm": { "pm": "bun", "source": "packageManager", - "version": "1.3.14", + "version": "1.0.0", "on_fail": "ignore" }, "path_probe": { - "bun": "/home/kjanat/.bun/bin/bun", - "npm": "/home/kjanat/.volta/bin/npm", - "pnpm": "/home/kjanat/.volta/bin/pnpm", - "yarn": "/home/kjanat/.volta/bin/yarn" + "bun": "/usr/local/bin/bun", + "npm": "/opt/volta/bin/npm", + "pnpm": null, + "yarn": "/opt/volta/bin/yarn" }, "volta_shims": { "npm": { - "resolved": "/home/kjanat/.volta/tools/image/npm/11.6.2/bin/npm" - }, - "pnpm": { - "resolved": "/home/kjanat/.volta/tools/image/packages/pnpm/bin/pnpm" + "resolved": "/opt/volta/tools/image/npm/10.0.0/bin/npm" }, "yarn": { - "resolved": "/home/kjanat/.volta/tools/image/yarn/1.22.22/bin/yarn" + "resolved": null } } } diff --git a/schemas/doctor.v2.schema.json b/schemas/doctor.v2.schema.json index e9dc8f1..5c17acb 100644 --- a/schemas/doctor.v2.schema.json +++ b/schemas/doctor.v2.schema.json @@ -402,7 +402,7 @@ } }, "title": "runner doctor --json --schema-version 2", - "description": "JSON schema for the current v2 `runner doctor --json` document. v2 uses tool-name task source labels.", + "description": "JSON schema for the v2 `runner doctor --json` document. v2 uses tool-name task source labels.", "type": "object", "required": [ "decisions", diff --git a/schemas/doctor.v3-draft.schema.json b/schemas/doctor.v3-draft.schema.json deleted file mode 100644 index 058d8ef..0000000 --- a/schemas/doctor.v3-draft.schema.json +++ /dev/null @@ -1,880 +0,0 @@ -{ - "$id": "https://kjanat.github.io/schemas/doctor.v3.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$defs": { - "absolute_path": { - "description": "Absolute path as emitted by the host platform. Kept intentionally permissive for POSIX, Windows, and virtual paths.", - "type": "string", - "minLength": 1 - }, - "conflict": { - "type": "object", - "required": [ - "kind", - "reason", - "selected", - "selector", - "severity", - "shadowed" - ], - "properties": { - "kind": { - "$ref": "#/$defs/non_empty_string" - }, - "reason": { - "$ref": "#/$defs/non_empty_string" - }, - "selected": { - "$ref": "#/$defs/fqn" - }, - "selector": { - "$ref": "#/$defs/non_empty_string" - }, - "severity": { - "$ref": "#/$defs/severity" - }, - "shadowed": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "$ref": "#/$defs/fqn" - } - } - }, - "additionalProperties": false - }, - "dependency": { - "type": "object", - "required": [ - "constraints", - "kind", - "name", - "required", - "resolved" - ], - "properties": { - "constraints": { - "$ref": "#/$defs/version_constraints" - }, - "kind": { - "$ref": "#/$defs/dependency_kind" - }, - "name": { - "$ref": "#/$defs/non_empty_string" - }, - "required": { - "type": "boolean" - }, - "resolved": { - "$ref": "#/$defs/dependency_resolution" - } - }, - "additionalProperties": false - }, - "dependency_found": { - "type": "object", - "required": [ - "found", - "satisfies" - ], - "properties": { - "found": { - "type": "boolean", - "const": true - }, - "path": { - "$ref": "#/$defs/nullable_string" - }, - "satisfies": { - "type": "boolean" - }, - "version": { - "$ref": "#/$defs/nullable_string" - }, - "via": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "dependency_kind": { - "type": "string", - "enum": [ - "binary", - "package-binary", - "runtime", - "package-manager", - "task-runner" - ] - }, - "dependency_missing": { - "type": "object", - "required": [ - "found" - ], - "properties": { - "found": { - "type": "boolean", - "const": false - }, - "via": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "dependency_resolution": { - "oneOf": [ - { - "$ref": "#/$defs/dependency_found" - }, - { - "$ref": "#/$defs/dependency_missing" - } - ] - }, - "diagnostic": { - "type": "object", - "required": [ - "code", - "message", - "severity" - ], - "properties": { - "code": { - "$ref": "#/$defs/non_empty_string" - }, - "details": { - "$ref": "#/$defs/json_value" - }, - "message": { - "$ref": "#/$defs/non_empty_string" - }, - "severity": { - "$ref": "#/$defs/severity" - }, - "source": { - "$ref": "#/$defs/nullable_string" - }, - "task": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/$defs/fqn" - } - ] - } - }, - "additionalProperties": false - }, - "ecosystem": { - "type": "object", - "required": [ - "decision", - "name", - "root", - "selected_package_manager", - "signals" - ], - "properties": { - "decision": { - "$ref": "#/$defs/ecosystem_decision" - }, - "name": { - "$ref": "#/$defs/non_empty_string" - }, - "root": { - "$ref": "#/$defs/absolute_path" - }, - "selected_package_manager": { - "$ref": "#/$defs/nullable_string" - }, - "signals": { - "$ref": "#/$defs/signals" - } - }, - "additionalProperties": false - }, - "ecosystem_decision": { - "type": "object", - "required": [ - "confidence", - "reason", - "selected" - ], - "properties": { - "confidence": { - "type": "string", - "enum": [ - "high", - "medium", - "low", - "none" - ] - }, - "reason": { - "$ref": "#/$defs/non_empty_string" - }, - "selected": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "environment": { - "type": "object", - "required": [ - "arch", - "os", - "path_entries", - "shell" - ], - "properties": { - "arch": { - "$ref": "#/$defs/non_empty_string" - }, - "os": { - "$ref": "#/$defs/non_empty_string" - }, - "path_entries": { - "type": "array", - "items": { - "$ref": "#/$defs/absolute_path" - } - }, - "shell": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "fqn": { - "description": "Fully qualified task name: ::. The task-name segment may itself contain colons.", - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*:[A-Za-z][A-Za-z0-9._-]*:.+$" - }, - "invocation": { - "type": "object", - "required": [ - "argv", - "cwd", - "started_at" - ], - "properties": { - "argv": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/non_empty_string" - } - }, - "cwd": { - "$ref": "#/$defs/absolute_path" - }, - "started_at": { - "type": "string", - "format": "date-time" - } - }, - "additionalProperties": false - }, - "json_value": { - "description": "Arbitrary JSON value.", - "oneOf": [ - { - "type": "null" - }, - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "array", - "items": { - "$ref": "#/$defs/json_value" - } - }, - { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/json_value" - } - } - ] - }, - "non_empty_string": { - "type": "string", - "minLength": 1 - }, - "nullable_string": { - "type": [ - "null", - "string" - ] - }, - "overrides": { - "type": "object", - "required": [ - "explain", - "fallback", - "no_warnings", - "on_mismatch", - "pm", - "pm_by_ecosystem", - "prefer_runners", - "runner" - ], - "properties": { - "explain": { - "type": "boolean" - }, - "fallback": { - "type": "string", - "enum": [ - "probe", - "npm", - "error" - ] - }, - "no_warnings": { - "type": "boolean" - }, - "on_mismatch": { - "type": "string", - "enum": [ - "warn", - "error", - "ignore" - ] - }, - "pm": { - "$ref": "#/$defs/nullable_string" - }, - "pm_by_ecosystem": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/nullable_string" - } - }, - "prefer_runners": { - "type": "array", - "items": { - "$ref": "#/$defs/non_empty_string" - } - }, - "runner": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "package_identity": { - "type": "object", - "required": [ - "name", - "source" - ], - "properties": { - "name": { - "$ref": "#/$defs/nullable_string" - }, - "source": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "project": { - "type": "object", - "required": [ - "monorepo", - "root", - "root_source", - "workspace" - ], - "properties": { - "monorepo": { - "type": "boolean" - }, - "root": { - "$ref": "#/$defs/absolute_path" - }, - "root_source": { - "$ref": "#/$defs/absolute_path" - }, - "workspace": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/$defs/workspace" - } - ] - } - }, - "additionalProperties": false - }, - "relative_path": { - "type": "string", - "minLength": 1 - }, - "resolution": { - "type": "object", - "required": [ - "fqn_policy", - "precedence", - "short_name_policy" - ], - "properties": { - "fqn_policy": { - "type": "string", - "enum": [ - "exact-only" - ] - }, - "precedence": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "$ref": "#/$defs/non_empty_string" - } - }, - "short_name_policy": { - "type": "string", - "enum": [ - "deterministic-precedence", - "ambiguous-error", - "first-match" - ] - } - }, - "additionalProperties": false - }, - "runner": { - "type": "object", - "required": [ - "binary", - "name", - "schema_versions", - "version" - ], - "properties": { - "binary": { - "$ref": "#/$defs/absolute_path" - }, - "name": { - "$ref": "#/$defs/non_empty_string" - }, - "schema_versions": { - "type": "object", - "required": [ - "doctor", - "list", - "why" - ], - "properties": { - "doctor": { - "type": "integer", - "const": 3 - }, - "list": { - "type": "integer", - "minimum": 1 - }, - "why": { - "type": "integer", - "minimum": 1 - } - }, - "additionalProperties": false - }, - "version": { - "$ref": "#/$defs/non_empty_string" - } - }, - "additionalProperties": false - }, - "severity": { - "type": "string", - "enum": [ - "debug", - "info", - "warning", - "error" - ] - }, - "signals": { - "description": "Detection evidence. Deliberately flexible because each ecosystem has different signal types.", - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/json_value" - } - }, - "source": { - "type": "object", - "required": [ - "exists", - "id", - "kind", - "path", - "relpath", - "scope", - "task_pointer" - ], - "properties": { - "exists": { - "type": "boolean" - }, - "id": { - "$ref": "#/$defs/source_id" - }, - "kind": { - "$ref": "#/$defs/source_kind" - }, - "package": { - "oneOf": [ - { - "type": "null" - }, - { - "$ref": "#/$defs/package_identity" - } - ] - }, - "path": { - "$ref": "#/$defs/absolute_path" - }, - "relpath": { - "$ref": "#/$defs/relative_path" - }, - "scope": { - "$ref": "#/$defs/source_scope" - }, - "task_pointer": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "source_id": { - "type": "string", - "pattern": "^src:[A-Za-z0-9._:-]+$" - }, - "source_kind": { - "description": "Known examples: package-json, cargo-config, cargo-toml, justfile, makefile, taskfile, deno-json, pyproject-toml, mise-toml, bacon-toml.", - "type": "string", - "pattern": "^[A-Za-z][A-Za-z0-9._-]*$" - }, - "source_scope": { - "description": "Stable project-root-relative scope. Examples: root, site, crates.runner, examples.basic.", - "type": "string", - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" - }, - "task": { - "type": "object", - "required": [ - "aliases", - "cwd", - "definition", - "dependencies", - "description", - "fqn", - "name", - "resolved", - "source", - "source_pointer" - ], - "properties": { - "aliases": { - "type": "array", - "uniqueItems": true, - "items": { - "$ref": "#/$defs/non_empty_string" - } - }, - "cwd": { - "$ref": "#/$defs/absolute_path" - }, - "definition": { - "$ref": "#/$defs/nullable_string" - }, - "dependencies": { - "type": "array", - "items": { - "$ref": "#/$defs/dependency" - } - }, - "description": { - "$ref": "#/$defs/nullable_string" - }, - "fqn": { - "$ref": "#/$defs/fqn" - }, - "name": { - "$ref": "#/$defs/non_empty_string" - }, - "resolved": { - "$ref": "#/$defs/non_empty_string" - }, - "source": { - "$ref": "#/$defs/absolute_path" - }, - "source_pointer": { - "$ref": "#/$defs/nullable_string" - }, - "synthetic": { - "default": false, - "type": "boolean" - }, - "synthetic_reason": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "tool": { - "type": "object", - "required": [ - "id", - "kind", - "name", - "probe", - "required" - ], - "properties": { - "id": { - "$ref": "#/$defs/tool_id" - }, - "kind": { - "$ref": "#/$defs/dependency_kind" - }, - "name": { - "$ref": "#/$defs/non_empty_string" - }, - "probe": { - "$ref": "#/$defs/tool_probe" - }, - "required": { - "type": "boolean" - } - }, - "additionalProperties": false - }, - "tool_id": { - "type": "string", - "pattern": "^tool:[A-Za-z0-9._:-]+$" - }, - "tool_probe": { - "oneOf": [ - { - "$ref": "#/$defs/tool_probe_found" - }, - { - "$ref": "#/$defs/tool_probe_missing" - }, - { - "$ref": "#/$defs/tool_probe_error" - } - ] - }, - "tool_probe_error": { - "type": "object", - "required": [ - "message", - "status" - ], - "properties": { - "message": { - "$ref": "#/$defs/non_empty_string" - }, - "status": { - "type": "string", - "const": "error" - } - }, - "additionalProperties": false - }, - "tool_probe_found": { - "type": "object", - "required": [ - "path", - "status", - "version" - ], - "properties": { - "path": { - "$ref": "#/$defs/absolute_path" - }, - "status": { - "type": "string", - "const": "found" - }, - "version": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "tool_probe_missing": { - "type": "object", - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string", - "const": "missing" - } - }, - "additionalProperties": false - }, - "version_constraints": { - "type": "object", - "required": [ - "max_version", - "min_version", - "version" - ], - "properties": { - "max_version": { - "$ref": "#/$defs/nullable_string" - }, - "min_version": { - "$ref": "#/$defs/nullable_string" - }, - "version": { - "$ref": "#/$defs/nullable_string" - } - }, - "additionalProperties": false - }, - "workspace": { - "type": "object", - "required": [ - "kind", - "root", - "source" - ], - "properties": { - "kind": { - "$ref": "#/$defs/non_empty_string" - }, - "root": { - "$ref": "#/$defs/absolute_path" - }, - "source": { - "$ref": "#/$defs/absolute_path" - } - }, - "additionalProperties": false - } - }, - "title": "runner doctor --json --schema-version 3", - "description": "JSON schema for the emitted `runner doctor --json` document.", - "type": "object", - "required": [ - "$schema", - "conflicts", - "diagnostics", - "ecosystems", - "environment", - "invocation", - "kind", - "overrides", - "project", - "resolution", - "runner", - "schema_version", - "sources", - "tasks", - "tools" - ], - "properties": { - "$schema": { - "description": "Schema URI/reference for this document.", - "examples": [ - "https://kjanat.github.io/schemas/doctor.v3.schema.json", - "./doctor.v3.schema.json" - ], - "type": "string", - "format": "uri-reference" - }, - "conflicts": { - "type": "array", - "items": { - "$ref": "#/$defs/conflict" - } - }, - "diagnostics": { - "type": "array", - "items": { - "$ref": "#/$defs/diagnostic" - } - }, - "ecosystems": { - "type": "array", - "uniqueItems": true, - "items": { - "$ref": "#/$defs/ecosystem" - } - }, - "environment": { - "$ref": "#/$defs/environment" - }, - "invocation": { - "$ref": "#/$defs/invocation" - }, - "kind": { - "type": "string", - "const": "runner.doctor" - }, - "overrides": { - "$ref": "#/$defs/overrides" - }, - "project": { - "$ref": "#/$defs/project" - }, - "resolution": { - "$ref": "#/$defs/resolution" - }, - "runner": { - "$ref": "#/$defs/runner" - }, - "schema_version": { - "type": "integer", - "const": 3 - }, - "sources": { - "type": "array", - "uniqueItems": true, - "items": { - "$ref": "#/$defs/source" - } - }, - "tasks": { - "type": "array", - "uniqueItems": true, - "items": { - "$ref": "#/$defs/task" - } - }, - "tools": { - "type": "array", - "uniqueItems": true, - "items": { - "$ref": "#/$defs/tool" - } - } - }, - "additionalProperties": false -} diff --git a/schemas/doctor.v3.example.json b/schemas/doctor.v3.example.json new file mode 100644 index 0000000..925f323 --- /dev/null +++ b/schemas/doctor.v3.example.json @@ -0,0 +1,619 @@ +{ + "$schema": "https://kjanat.github.io/schemas/doctor.v3.schema.json", + "schema_version": 3, + "kind": "runner.doctor", + "invocation": { + "argv": [ + "runner", + "doctor", + "--json" + ], + "cwd": "/path/to/project", + "started_at": "2026-01-01T00:00:00Z" + }, + "environment": { + "arch": "x86_64", + "os": "linux", + "path_entries": [ + "/usr/local/bin", + "/usr/bin" + ], + "shell": "bash" + }, + "runner": { + "binary": "/usr/local/bin/runner", + "name": "runner", + "version": "0.13.0", + "schema_versions": { + "doctor": 3, + "list": 2, + "why": 3 + } + }, + "project": { + "monorepo": true, + "root": "/path/to/project", + "root_source": "/path/to/project", + "workspace": null + }, + "overrides": { + "explain": false, + "fallback": "probe", + "no_warnings": false, + "on_mismatch": "warn", + "pm": null, + "pm_by_ecosystem": {}, + "prefer_runners": [], + "runner": null + }, + "ecosystems": [ + { + "decision": { + "confidence": "high", + "reason": "bun via package.json \"packageManager\"", + "selected": "bun" + }, + "name": "node", + "root": "/path/to/project", + "selected_package_manager": "bun", + "signals": { + "lockfile_pm": "bun", + "manifest_pm": "bun", + "path_probe": { + "bun": "/usr/local/bin/bun", + "npm": "/opt/volta/bin/npm", + "pnpm": null, + "yarn": "/opt/volta/bin/yarn" + }, + "shims": { + "npm": { + "manager": "volta", + "resolved": "/opt/volta/tools/image/npm/10.0.0/bin/npm" + }, + "yarn": { + "manager": "volta", + "resolved": null + } + } + } + }, + { + "decision": { + "confidence": "high", + "reason": "detected via cargo project signal", + "selected": "cargo" + }, + "name": "rust", + "root": "/path/to/project", + "selected_package_manager": "cargo", + "signals": { + "package_managers": [ + "cargo" + ] + } + } + ], + "sources": [ + { + "exists": true, + "id": "src:root:package.json", + "kind": "package.json", + "package": null, + "path": "/path/to/project/package.json", + "relpath": "package.json", + "scope": "root", + "task_pointer": "scripts" + }, + { + "exists": true, + "id": "src:root:just", + "kind": "just", + "package": null, + "path": "/path/to/project/justfile", + "relpath": "justfile", + "scope": "root", + "task_pointer": null + }, + { + "exists": true, + "id": "src:root:cargo-alias", + "kind": "cargo-alias", + "package": null, + "path": "/path/to/project/.cargo/config.toml", + "relpath": ".cargo/config.toml", + "scope": "root", + "task_pointer": "alias" + } + ], + "tasks": [ + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:package.json:fmt", + "name": "fmt", + "resolved": "bun run fmt", + "source": "/path/to/project/package.json", + "source_pointer": "scripts.fmt" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:package.json:fmt:update", + "name": "fmt:update", + "resolved": "bun run fmt:update", + "source": "/path/to/project/package.json", + "source_pointer": "scripts.fmt:update" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:package.json:typecheck", + "name": "typecheck", + "resolved": "bun run typecheck", + "source": "/path/to/project/package.json", + "source_pointer": "scripts.typecheck" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just:build-packages", + "name": "build-packages", + "resolved": "just build-packages", + "source": "/path/to/project/justfile", + "source_pointer": "build-packages" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just:default", + "name": "default", + "resolved": "just default", + "source": "/path/to/project/justfile", + "source_pointer": "default" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/", + "fqn": "root:just:gen-schema", + "name": "gen-schema", + "resolved": "just gen-schema", + "source": "/path/to/project/justfile", + "source_pointer": "gen-schema" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just:ls", + "name": "ls", + "resolved": "just ls", + "source": "/path/to/project/justfile", + "source_pointer": "ls" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just:run", + "name": "run", + "resolved": "just run", + "source": "/path/to/project/justfile", + "source_pointer": "run" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": null, + "fqn": "root:just:runner", + "name": "runner", + "resolved": "just runner", + "source": "/path/to/project/justfile", + "source_pointer": "runner" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": null, + "dependencies": [], + "description": "Build release bin and verify the facade shims spawn the native binary.", + "fqn": "root:just:test-release", + "name": "test-release", + "resolved": "just test-release", + "source": "/path/to/project/justfile", + "source_pointer": "test-release" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "build", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:b", + "name": "b", + "resolved": "cargo build", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.b" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "build --bin run --bin runner", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:bb", + "name": "bb", + "resolved": "cargo build --bin run --bin runner", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.bb" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "build --bin run --bin runner --release", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:bbr", + "name": "bbr", + "resolved": "cargo build --bin run --bin runner --release", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.bbr" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin run", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:bin-run", + "name": "bin-run", + "resolved": "cargo run --quiet --bin run", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.bin-run" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin runner", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:bin-runner", + "name": "bin-runner", + "resolved": "cargo run --quiet --bin runner", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.bin-runner" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "check", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:c", + "name": "c", + "resolved": "cargo check", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.c" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "clippy --all-targets --all-features", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:cl", + "name": "cl", + "resolved": "cargo clippy --all-targets --all-features", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.cl" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin runner -- completions", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:comp", + "name": "comp", + "resolved": "cargo run --quiet --bin runner -- completions", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.comp" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "doc", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:d", + "name": "d", + "resolved": "cargo doc", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.d" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin run -- --pm npm dprint fmt", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:f", + "name": "f", + "resolved": "cargo run --quiet --bin run -- --pm npm dprint fmt", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.f" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin run -- --pm npm dprint fmt", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:format", + "name": "format", + "resolved": "cargo run --quiet --bin run -- --pm npm dprint fmt", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.format" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "install --path .", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:i", + "name": "i", + "resolved": "cargo install --path .", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.i" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "clippy --all-targets --all-features -- -D warnings -D clippy::all", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:l", + "name": "l", + "resolved": "cargo clippy --all-targets --all-features -- -D warnings -D clippy::all", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.l" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "clippy --all-targets --all-features -- -D warnings -D clippy::all", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:lint", + "name": "lint", + "resolved": "cargo clippy --all-targets --all-features -- -D warnings -D clippy::all", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.lint" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --features man -- man", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:man", + "name": "man", + "resolved": "cargo run --quiet --features man -- man", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.man" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "metadata --format-version 1", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:meta", + "name": "meta", + "resolved": "cargo metadata --format-version 1", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.meta" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:r", + "name": "r", + "resolved": "cargo run", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.r" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin run --release", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:rbin-run", + "name": "rbin-run", + "resolved": "cargo run --quiet --bin run --release", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rbin-run" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin runner --release", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:rbin-runner", + "name": "rbin-runner", + "resolved": "cargo run --quiet --bin runner --release", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rbin-runner" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "remove", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:rm", + "name": "rm", + "resolved": "cargo remove", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rm" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:rq", + "name": "rq", + "resolved": "cargo run --quiet", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rq" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --release", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:rr", + "name": "rr", + "resolved": "cargo run --release", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.rr" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --bin runner", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:runner", + "name": "runner", + "resolved": "cargo run --quiet --bin runner", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.runner" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "run --quiet --features schema -- schema", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:schema", + "name": "schema", + "resolved": "cargo run --quiet --features schema -- schema", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.schema" + }, + { + "aliases": [], + "cwd": "/path/to/project", + "definition": "test", + "dependencies": [], + "description": null, + "fqn": "root:cargo-alias:t", + "name": "t", + "resolved": "cargo test", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.t" + } + ], + "tools": [ + { + "id": "tool:runtime:node", + "kind": "runtime", + "name": "node", + "probe": { + "status": "found", + "path": "/usr/bin/node", + "version": "24.0.0" + }, + "required": true + }, + { + "id": "tool:package-manager:bun", + "kind": "package-manager", + "name": "bun", + "probe": { + "status": "found", + "path": "/usr/bin/bun", + "version": null + }, + "required": true + }, + { + "id": "tool:package-manager:cargo", + "kind": "package-manager", + "name": "cargo", + "probe": { + "status": "found", + "path": "/usr/bin/cargo", + "version": null + }, + "required": true + }, + { + "id": "tool:task-runner:just", + "kind": "task-runner", + "name": "just", + "probe": { + "status": "found", + "path": "/usr/bin/just", + "version": null + }, + "required": true + } + ], + "conflicts": [ + { + "kind": "duplicate-task-name", + "reason": "2 sources define `runner`; lowest (source_priority=2, source_depth=0, display_order=2, alias-last) key wins", + "selected": "root:just:runner", + "selector": "runner", + "severity": "info", + "shadowed": [ + "root:cargo-alias:runner" + ] + } + ], + "diagnostics": [], + "resolution": { + "fqn_policy": "exact-only", + "precedence": [ + "source-priority", + "source-depth", + "display-order", + "alias-last" + ], + "short_name_policy": "deterministic-precedence" + } +} diff --git a/schemas/doctor.v3.schema.json b/schemas/doctor.v3.schema.json new file mode 100644 index 0000000..4db9f9d --- /dev/null +++ b/schemas/doctor.v3.schema.json @@ -0,0 +1,671 @@ +{ + "$id": "https://kjanat.github.io/schemas/doctor.v3.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "ConfidenceV3": { + "description": "How sure the resolver is about an ecosystem's PM selection.", + "oneOf": [ + { + "description": "Explicit signal: override, manifest declaration, or lockfile.", + "type": "string", + "const": "high" + }, + { + "description": "Inferred: PATH probe found a usable binary.", + "type": "string", + "const": "medium" + }, + { + "description": "Legacy `--fallback npm` default with no signal at all.", + "type": "string", + "const": "low" + }, + { + "description": "Resolution failed.", + "type": "string", + "const": "none" + } + ] + }, + "ConflictV3": { + "description": "A task name claimed by more than one source: who wins, who is shadowed.", + "type": "object", + "required": [ + "kind", + "reason", + "selected", + "selector", + "severity", + "shadowed" + ], + "properties": { + "kind": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "selected": { + "description": "FQN of the winning task.", + "type": "string" + }, + "selector": { + "type": "string" + }, + "severity": { + "$ref": "#/$defs/SeverityV3" + }, + "shadowed": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "DependencyKindV3": { + "description": "What kind of thing a probed tool is. The draft's `binary` /\n`package-binary` kinds join when something probes them.", + "type": "string", + "enum": [ + "runtime", + "package-manager", + "task-runner" + ] + }, + "DiagnosticV3": { + "description": "One detection/resolution diagnostic, flattened from the warning\nstreams.", + "type": "object", + "required": [ + "code", + "message", + "severity", + "source", + "task" + ], + "properties": { + "code": { + "description": "Stable warning category (the warning's source subsystem).", + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/$defs/SeverityV3" + }, + "source": { + "type": [ + "null", + "string" + ] + }, + "task": { + "type": [ + "null", + "string" + ] + } + } + }, + "DoctorTaskV3": { + "description": "One task in the doctor inventory. Same identity scheme as `why` v3\n(`fqn`, `source_pointer`, `aliases`, `definition`, `resolved`).", + "type": "object", + "required": [ + "aliases", + "cwd", + "definition", + "dependencies", + "description", + "fqn", + "name", + "resolved", + "source", + "source_pointer" + ], + "properties": { + "aliases": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "definition": { + "type": [ + "null", + "string" + ] + }, + "dependencies": { + "description": "Task dependencies. Always empty today: no extractor records dependency edges yet; the edge shape lands with the first extractor.", + "type": "array", + "items": true + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "fqn": { + "type": "string" + }, + "name": { + "type": "string" + }, + "resolved": { + "description": "Effective command preview. Null when it depends on a PM resolution that failed.", + "type": [ + "null", + "string" + ] + }, + "source": { + "type": [ + "null", + "string" + ] + }, + "source_pointer": { + "type": [ + "null", + "string" + ] + } + } + }, + "EcosystemDecisionV3": { + "type": "object", + "required": [ + "confidence", + "reason", + "selected" + ], + "properties": { + "confidence": { + "$ref": "#/$defs/ConfidenceV3" + }, + "reason": { + "type": "string" + }, + "selected": { + "type": [ + "null", + "string" + ] + } + } + }, + "EcosystemV3": { + "description": "One detected ecosystem and the PM decision made for it.", + "type": "object", + "required": [ + "decision", + "name", + "root", + "selected_package_manager", + "signals" + ], + "properties": { + "decision": { + "$ref": "#/$defs/EcosystemDecisionV3" + }, + "name": { + "type": "string" + }, + "root": { + "type": "string" + }, + "selected_package_manager": { + "type": [ + "null", + "string" + ] + }, + "signals": { + "description": "Detection evidence. Node carries the full signal set (lockfile/manifest/PATH probe/shim classification, keyed by tool with the shim manager as data); other ecosystems list their detected package managers." + } + } + }, + "EnvironmentV3": { + "description": "Host facts that influence probing and dispatch.", + "type": "object", + "required": [ + "arch", + "os", + "path_entries", + "shell" + ], + "properties": { + "arch": { + "type": "string" + }, + "os": { + "type": "string" + }, + "path_entries": { + "type": "array", + "items": { + "type": "string" + } + }, + "shell": { + "type": [ + "null", + "string" + ] + } + } + }, + "InvocationV3": { + "description": "How this report came to be: the exact process invocation.", + "type": "object", + "required": [ + "argv", + "cwd", + "started_at" + ], + "properties": { + "argv": { + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "started_at": { + "description": "UTC RFC 3339 timestamp of report generation.", + "type": "string" + } + } + }, + "OverridesV3": { + "description": "Effective override stack, labels only. Provenance (cli/env/config)\nstays on the v2 surface.", + "type": "object", + "required": [ + "explain", + "fallback", + "no_warnings", + "on_mismatch", + "pm", + "pm_by_ecosystem", + "prefer_runners", + "runner" + ], + "properties": { + "explain": { + "type": "boolean" + }, + "fallback": { + "type": "string" + }, + "no_warnings": { + "type": "boolean" + }, + "on_mismatch": { + "type": "string" + }, + "pm": { + "type": [ + "null", + "string" + ] + }, + "pm_by_ecosystem": { + "type": "object", + "additionalProperties": { + "type": [ + "null", + "string" + ] + } + }, + "prefer_runners": { + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "type": [ + "null", + "string" + ] + } + } + }, + "ProjectInfoV3": { + "description": "Project anchoring facts.", + "type": "object", + "required": [ + "monorepo", + "root", + "root_source", + "workspace" + ], + "properties": { + "monorepo": { + "type": "boolean" + }, + "root": { + "type": "string" + }, + "root_source": { + "description": "What anchored root detection. Currently always the root itself (cwd or --dir); a dedicated anchor model is future work.", + "type": "string" + }, + "workspace": { + "description": "Workspace identity. Always null today: workspace kind/root detection is not yet modeled (the monorepo flag is the coarse signal)." + } + } + }, + "ResolutionPolicyV3": { + "description": "Self-description of the task-selection policy, so consumers don't\nhardcode runner's precedence rules.", + "type": "object", + "required": [ + "fqn_policy", + "precedence", + "short_name_policy" + ], + "properties": { + "fqn_policy": { + "type": "string" + }, + "precedence": { + "type": "array", + "items": { + "type": "string" + } + }, + "short_name_policy": { + "type": "string" + } + } + }, + "RunnerInfoV3": { + "description": "The reporting binary's own identity and contract versions.", + "type": "object", + "required": [ + "binary", + "name", + "schema_versions", + "version" + ], + "properties": { + "binary": { + "type": "string" + }, + "name": { + "type": "string" + }, + "schema_versions": { + "$ref": "#/$defs/SchemaVersionsV3" + }, + "version": { + "type": "string" + } + } + }, + "SchemaVersionsV3": { + "description": "Latest schema version each `--json` surface speaks.", + "type": "object", + "required": [ + "doctor", + "list", + "why" + ], + "properties": { + "doctor": { + "type": "integer", + "minimum": 0, + "format": "uint32" + }, + "list": { + "type": "integer", + "minimum": 0, + "format": "uint32" + }, + "why": { + "type": "integer", + "minimum": 0, + "format": "uint32" + } + } + }, + "SeverityV3": { + "description": "Severity of a conflict or diagnostic. The draft's `debug`/`error`\nlevels join when something emits them.", + "type": "string", + "enum": [ + "info", + "warning" + ] + }, + "SourceV3": { + "description": "One task-source config file as a first-class object.", + "type": "object", + "required": [ + "exists", + "id", + "kind", + "package", + "path", + "relpath", + "scope", + "task_pointer" + ], + "properties": { + "exists": { + "type": "boolean" + }, + "id": { + "description": "Stable source identity: `src::`.", + "type": "string" + }, + "kind": { + "$ref": "#/$defs/TaskSourceLabel" + }, + "package": { + "description": "Package identity for manifest-backed sources. Null today." + }, + "path": { + "type": "string" + }, + "relpath": { + "type": "string" + }, + "scope": { + "description": "Project-root-relative scope; `root` until member scoping lands.", + "type": "string" + }, + "task_pointer": { + "description": "Key of the container holding tasks inside the file (`scripts`, `tasks`, `alias`, …); null for flat-format files.", + "type": [ + "null", + "string" + ] + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo-alias", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + }, + "ToolProbeV3": { + "description": "PATH-probe outcome, tagged by `status`.", + "oneOf": [ + { + "type": "object", + "required": [ + "path", + "status", + "version" + ], + "properties": { + "path": { + "type": "string" + }, + "status": { + "type": "string", + "const": "found" + }, + "version": { + "description": "Version when already known from detection (probing never executes binaries).", + "type": [ + "null", + "string" + ] + } + } + }, + { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "const": "missing" + } + } + } + ] + }, + "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" + } + } + } + }, + "title": "runner doctor --json --schema-version 3", + "description": "JSON schema for the current v3 `runner doctor --json` document: structured diagnostic inventory with invocation/environment provenance, per-ecosystem decisions, sources, fqn-keyed tasks, tools, conflicts, and diagnostics.", + "type": "object", + "required": [ + "$schema", + "conflicts", + "diagnostics", + "ecosystems", + "environment", + "invocation", + "kind", + "overrides", + "project", + "resolution", + "runner", + "schema_version", + "sources", + "tasks", + "tools" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/$defs/ConflictV3" + } + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/DiagnosticV3" + } + }, + "ecosystems": { + "type": "array", + "items": { + "$ref": "#/$defs/EcosystemV3" + } + }, + "environment": { + "$ref": "#/$defs/EnvironmentV3" + }, + "invocation": { + "$ref": "#/$defs/InvocationV3" + }, + "kind": { + "description": "Payload discriminator; always \"runner.doctor\".", + "type": "string" + }, + "overrides": { + "$ref": "#/$defs/OverridesV3" + }, + "project": { + "$ref": "#/$defs/ProjectInfoV3" + }, + "resolution": { + "$ref": "#/$defs/ResolutionPolicyV3" + }, + "runner": { + "$ref": "#/$defs/RunnerInfoV3" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 3, + "minimum": 0, + "format": "uint32" + }, + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/SourceV3" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/$defs/DoctorTaskV3" + } + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/$defs/ToolV3" + } + } + } +} diff --git a/schemas/list.v1.example.json b/schemas/list.v1.example.json index 7b9475e..f56c1cf 100644 --- a/schemas/list.v1.example.json +++ b/schemas/list.v1.example.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "root": "/home/kjanat/projects/runner", + "root": "/path/to/project", "tasks": [ { "name": "fmt", diff --git a/schemas/list.v2.example.json b/schemas/list.v2.example.json index 139e3ea..8d37b56 100644 --- a/schemas/list.v2.example.json +++ b/schemas/list.v2.example.json @@ -1,6 +1,6 @@ { "schema_version": 2, - "root": "/home/kjanat/projects/runner", + "root": "/path/to/project", "tasks": [ { "name": "fmt", diff --git a/schemas/why.v1.example.json b/schemas/why.v1.example.json index f791bf5..182b1af 100644 --- a/schemas/why.v1.example.json +++ b/schemas/why.v1.example.json @@ -11,7 +11,7 @@ "alias_of": "test", "description": null, "passthrough_to": null, - "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + "source_dir": "/path/to/project/.cargo/config.toml" } ], "selected": { @@ -23,7 +23,7 @@ "alias_of": "test", "description": null, "passthrough_to": null, - "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + "source_dir": "/path/to/project/.cargo/config.toml" }, "pm_resolution": null } diff --git a/schemas/why.v2.example.json b/schemas/why.v2.example.json index 363b1d0..fac81d0 100644 --- a/schemas/why.v2.example.json +++ b/schemas/why.v2.example.json @@ -11,7 +11,7 @@ "alias_of": "test", "description": null, "passthrough_to": null, - "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + "source_dir": "/path/to/project/.cargo/config.toml" } ], "selected": { @@ -23,7 +23,7 @@ "alias_of": "test", "description": null, "passthrough_to": null, - "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + "source_dir": "/path/to/project/.cargo/config.toml" }, "pm_resolution": null } diff --git a/schemas/why.v3-draft.example.json b/schemas/why.v3-draft.example.json deleted file mode 100644 index bc028e0..0000000 --- a/schemas/why.v3-draft.example.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "schema_version": 3, - "kind": "runner.why", - "root": "/home/kjanat/projects/runner", - "query": "t", - - "pm_resolution": null, - - "selected": { - "task": { - "name": "t", - "fqn": "root:cargo-alias:t", - "provider": "cargo", - "kind": "cargo-alias", - "source": "/home/kjanat/projects/runner/.cargo/config.toml", - "source_pointer": "alias.t", - "description": null, - "aliases": [], - "definition": "test", - "resolved": "cargo test", - "cwd": "/home/kjanat/projects/runner", - "dependencies": [] - }, - "match": { - "selector": "t", - "matched_by": "name", - "depth": 0, - "display_order": 6, - "source_priority": 2, - "is_alias": true, - "passthrough_to": null - } - }, - - "candidates": [ - { - "task": { - "name": "t", - "fqn": "root:cargo-alias:t", - "provider": "cargo", - "kind": "cargo-alias", - "source": "/home/kjanat/projects/runner/.cargo/config.toml", - "source_pointer": "alias.t", - "description": null, - "aliases": [], - "definition": "test", - "resolved": "cargo test", - "cwd": "/home/kjanat/projects/runner", - "dependencies": [] - }, - "match": { - "selector": "t", - "matched_by": "name", - "depth": 0, - "display_order": 6, - "source_priority": 2, - "is_alias": true, - "passthrough_to": null - } - } - ], - - "decision": { - "strategy": "single-candidate", - "reason": "exact task name matched one candidate" - } -} diff --git a/schemas/why.v3.example.json b/schemas/why.v3.example.json new file mode 100644 index 0000000..7744428 --- /dev/null +++ b/schemas/why.v3.example.json @@ -0,0 +1,63 @@ +{ + "schema_version": 3, + "kind": "runner.why", + "root": "/path/to/project", + "query": "t", + "pm_resolution": null, + "selected": { + "task": { + "name": "t", + "fqn": "root:cargo-alias:t", + "provider": "cargo", + "kind": "cargo-alias", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.t", + "description": null, + "aliases": [], + "definition": "test", + "resolved": "cargo test", + "cwd": "/path/to/project", + "dependencies": [] + }, + "match": { + "selector": "t", + "matched_by": "name", + "depth": 0, + "display_order": 6, + "source_priority": 2, + "is_alias": true, + "passthrough_to": null + } + }, + "candidates": [ + { + "task": { + "name": "t", + "fqn": "root:cargo-alias:t", + "provider": "cargo", + "kind": "cargo-alias", + "source": "/path/to/project/.cargo/config.toml", + "source_pointer": "alias.t", + "description": null, + "aliases": [], + "definition": "test", + "resolved": "cargo test", + "cwd": "/path/to/project", + "dependencies": [] + }, + "match": { + "selector": "t", + "matched_by": "name", + "depth": 0, + "display_order": 6, + "source_priority": 2, + "is_alias": true, + "passthrough_to": null + } + } + ], + "decision": { + "strategy": "single-candidate", + "reason": "exact task name matched one candidate" + } +} diff --git a/schemas/why.v3.schema.json b/schemas/why.v3.schema.json new file mode 100644 index 0000000..0e301be --- /dev/null +++ b/schemas/why.v3.schema.json @@ -0,0 +1,322 @@ +{ + "$id": "https://kjanat.github.io/schemas/why.v3.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "PmResolution": { + "anyOf": [ + { + "type": "object", + "required": [ + "pm", + "via", + "warnings" + ], + "properties": { + "pm": { + "type": "string" + }, + "via": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyWarning" + } + } + } + }, + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + ] + }, + "ProviderLabel": { + "type": "string", + "enum": [ + "node", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "python" + ] + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo-alias", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + }, + "WhyCandidateV3": { + "description": "One candidate: the task's identity plus how it matched the query.", + "type": "object", + "required": [ + "match", + "task" + ], + "properties": { + "match": { + "$ref": "#/$defs/WhyMatchV3" + }, + "task": { + "$ref": "#/$defs/WhyTaskV3" + } + } + }, + "WhyDecisionV3": { + "type": "object", + "required": [ + "reason", + "strategy" + ], + "properties": { + "reason": { + "type": "string" + }, + "strategy": { + "description": "Selection branch taken: `single-candidate`, `ranked`, `filtered`, or `exec-fallback`.", + "type": "string" + } + } + }, + "WhyMatchV3": { + "type": "object", + "required": [ + "depth", + "display_order", + "is_alias", + "matched_by", + "passthrough_to", + "selector", + "source_priority" + ], + "properties": { + "depth": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "format": "uint" + }, + "display_order": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "format": "uint8" + }, + "is_alias": { + "type": "boolean" + }, + "matched_by": { + "description": "How the selector matched. `why` matches exact names only today.", + "type": "string" + }, + "passthrough_to": { + "type": [ + "null", + "string" + ] + }, + "selector": { + "type": "string" + }, + "source_priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "format": "uint16" + } + } + }, + "WhyTaskV3": { + "type": "object", + "required": [ + "aliases", + "cwd", + "definition", + "dependencies", + "description", + "fqn", + "kind", + "name", + "provider", + "resolved", + "source", + "source_pointer" + ], + "properties": { + "aliases": { + "description": "Names of sibling alias tasks that resolve to this task.", + "type": "array", + "items": { + "type": "string" + } + }, + "cwd": { + "type": "string" + }, + "definition": { + "description": "Raw definition target: alias expansion or tool-specific run target.", + "type": [ + "null", + "string" + ] + }, + "dependencies": { + "description": "Task dependencies. Always empty today: no extractor records dependency edges yet.", + "type": "array", + "items": { + "type": "string" + } + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "fqn": { + "description": "Stable task identity: `::`. Scope is `root` until workspace-member scoping lands.", + "type": "string" + }, + "kind": { + "$ref": "#/$defs/TaskSourceLabel" + }, + "name": { + "type": "string" + }, + "provider": { + "$ref": "#/$defs/ProviderLabel" + }, + "resolved": { + "description": "Effective command preview. Null when it depends on a PM resolution that was not performed for this candidate.", + "type": [ + "null", + "string" + ] + }, + "source": { + "description": "Config file the task was extracted from, when resolvable.", + "type": [ + "null", + "string" + ] + }, + "source_pointer": { + "description": "Locator inside the source file: a key path for structured configs (`alias.t`, `scripts.test`), the target/recipe name for flat files.", + "type": [ + "null", + "string" + ] + } + } + }, + "WhyWarning": { + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + }, + "title": "runner why --json --schema-version 3", + "description": "JSON schema for `runner why --json --schema-version 3`.", + "type": "object", + "required": [ + "candidates", + "decision", + "kind", + "pm_resolution", + "query", + "root", + "schema_version", + "selected" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyCandidateV3" + } + }, + "decision": { + "$ref": "#/$defs/WhyDecisionV3" + }, + "kind": { + "description": "Payload discriminator; always \"runner.why\".", + "type": "string" + }, + "pm_resolution": { + "anyOf": [ + { + "$ref": "#/$defs/PmResolution" + }, + { + "type": "null" + } + ] + }, + "query": { + "description": "The task selector as the user typed it.", + "type": "string" + }, + "root": { + "description": "Project root the query ran against.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 3, + "minimum": 0, + "format": "uint32" + }, + "selected": { + "anyOf": [ + { + "$ref": "#/$defs/WhyCandidateV3" + }, + { + "type": "null" + } + ] + } + } +} diff --git a/src/cli.rs b/src/cli.rs index 394d0fa..174c1e4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -845,19 +845,23 @@ pub(crate) struct GlobalOpts { pub no_warnings: bool, /// Pin the JSON output schema to a specific version. Defaults to the - /// latest version this binary produces. The chosen version controls + /// latest version the command produces. The chosen version controls /// the `source` field on tasks/decisions in `doctor`/`list`/`why` /// JSON output. v1 uses filename-style labels (`"justfile"`, `"bacon.toml"`), - /// v2 uses tool names (`"just"`, `"bacon"`). The resolver, human output, - /// and qualified-task parsing are unaffected. + /// v2 uses tool names (`"just"`, `"bacon"`). v3 restructures the + /// reports: `why` gains `{task, match}` candidate pairs plus a + /// decision block, `doctor` becomes a structured diagnostic + /// inventory; `list` rejects v3 until its contract lands. The + /// resolver, human output, and qualified-task parsing are unaffected. #[arg( long = "schema-version", global = true, - value_parser = clap::value_parser!(u32).range(1..=2), + value_parser = clap::value_parser!(u32).range(1..=3), value_name = "N", help = concat!( - "Pin JSON output schema version (", - cyan!("1"), " or ", cyan!("2"), "). Defaults to latest. Affects ", + "Pin JSON output schema version (doctor/why: ", + cyan!("1"), "-", cyan!("3"), ", list: ", + cyan!("1"), "-", cyan!("2"), "). Defaults to latest. Affects ", cyan!("--json"), " output of doctor/list/why only." ), )] diff --git a/src/cmd/doctor.rs b/src/cmd/doctor.rs index 6abb689..316f3d1 100644 --- a/src/cmd/doctor.rs +++ b/src/cmd/doctor.rs @@ -34,7 +34,23 @@ pub(crate) fn doctor( json: bool, schema_version: u32, ) -> Result<()> { - let project = Project::build_with_schema(ctx, overrides, schema_version, true); + if json && schema_version >= 3 { + // v3 restructured the whole document; v1/v2 keep the flat + // `Project` shape below. + let report = crate::schema::doctor_v3::DoctorReportV3::build(ctx, overrides, true); + println!("{}", serde_json::to_string_pretty(&report)?); + return Ok(()); + } + + // The human renderer still reads the flat v2 `Project` shape, so a + // non-JSON call always builds at the v2 contract regardless of the + // requested (or defaulted) version. + let build_version = if json { + schema_version + } else { + crate::schema::CURRENT_VERSION + }; + let project = Project::build_with_schema(ctx, overrides, build_version, true); if json { println!("{}", serde_json::to_string_pretty(&project)?); diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index c960e0e..2403c8b 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -57,6 +57,10 @@ fn schema_documents() -> Result> { filename: "doctor.v2.schema.json", value: output_schema::>("doctor", 2)?, }, + SchemaDocument { + filename: "doctor.v3.schema.json", + value: output_schema::>("doctor", 3)?, + }, SchemaDocument { filename: "list.v1.schema.json", value: output_schema::>("list", 1)?, @@ -73,6 +77,10 @@ fn schema_documents() -> Result> { filename: "why.v2.schema.json", value: output_schema::>("why", 2)?, }, + SchemaDocument { + filename: "why.v3.schema.json", + value: output_schema::>("why", 3)?, + }, ]) } @@ -144,33 +152,61 @@ fn patch_source_schema(schema: &mut Value, version: u32) { ); patch_task_info_source(defs); patch_why_candidate_source(defs); + patch_why_task_v3(defs); + patch_def_field(defs, "SourceV3", "kind", "TaskSourceLabel"); } fn patch_task_info_source(defs: &mut Map) { - patch_def_source(defs, "TaskInfo"); + patch_def_field(defs, "TaskInfo", "source", "TaskSourceLabel"); } fn patch_why_candidate_source(defs: &mut Map) { - patch_def_source(defs, "WhyCandidate"); + patch_def_field(defs, "WhyCandidate", "source", "TaskSourceLabel"); +} + +/// The v3 `why` task object splits the old `source` label into `kind` +/// (mechanism label) and `provider` (executing tool family); constrain +/// both to their closed label sets. +fn patch_why_task_v3(defs: &mut Map) { + if !defs.contains_key("WhyTaskV3") { + return; + } + defs.insert( + "ProviderLabel".to_string(), + json!({ "type": "string", "enum": PROVIDER_LABELS }), + ); + patch_def_field(defs, "WhyTaskV3", "kind", "TaskSourceLabel"); + patch_def_field(defs, "WhyTaskV3", "provider", "ProviderLabel"); } -fn patch_def_source(defs: &mut Map, def_name: &'static str) { - let Some(source_schema) = defs +fn patch_def_field( + defs: &mut Map, + def_name: &'static str, + field: &'static str, + target_def: &'static str, +) { + let Some(field_schema) = defs .get_mut(def_name) .and_then(|definition| definition.get_mut("properties")) .and_then(Value::as_object_mut) - .and_then(|properties| properties.get_mut("source")) + .and_then(|properties| properties.get_mut(field)) else { return; }; - *source_schema = json!({ "$ref": "#/$defs/TaskSourceLabel" }); + *field_schema = json!({ "$ref": format!("#/$defs/{target_def}") }); } fn task_source_label_schema(version: u32) -> Value { json!({ "type": "string", "enum": source_labels(version) }) } -fn source_labels(version: u32) -> &'static [&'static str] { +/// Closed set for the v3 `provider` field — the tool family that +/// executes the task. Mirrors `cmd::why::provider_label`. +const PROVIDER_LABELS: &[&str] = &[ + "node", "make", "just", "task", "turbo", "deno", "cargo", "go", "bacon", "mise", "python", +]; + +const fn source_labels(version: u32) -> &'static [&'static str] { match version { 1 => &[ "package.json", @@ -185,7 +221,7 @@ fn source_labels(version: u32) -> &'static [&'static str] { "mise.toml", "pyproject.toml", ], - _ => &[ + 2 => &[ "package.json", "make", "just", @@ -198,11 +234,24 @@ fn source_labels(version: u32) -> &'static [&'static str] { "mise", "pyproject.toml", ], + _ => &[ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo-alias", + "go", + "bacon", + "mise", + "pyproject.toml", + ], } } fn schema_id(command: &str, version: u32) -> String { - format!("https://kjanat.github.io/schemas/{command}.v{version}.schema.json") + crate::schema::schema_url(command, version) } fn title(command: &str, version: u32) -> String { @@ -215,7 +264,8 @@ fn title(command: &str, version: u32) -> String { fn description(command: &str, version: u32) -> String { match (command, version) { ("doctor", 1) => "JSON schema for the legacy v1 `runner doctor --json` document. v1 uses filename-style task source labels.".to_string(), - ("doctor", _) => "JSON schema for the current v2 `runner doctor --json` document. v2 uses tool-name task source labels.".to_string(), + ("doctor", 2) => "JSON schema for the v2 `runner doctor --json` document. v2 uses tool-name task source labels.".to_string(), + ("doctor", _) => "JSON schema for the current v3 `runner doctor --json` document: structured diagnostic inventory with invocation/environment provenance, per-ecosystem decisions, sources, fqn-keyed tasks, tools, conflicts, and diagnostics.".to_string(), _ => format!("JSON schema for `{}`.", title(command, version)), } } diff --git a/src/cmd/why.rs b/src/cmd/why.rs index d97e154..cc1aaa3 100644 --- a/src/cmd/why.rs +++ b/src/cmd/why.rs @@ -55,17 +55,27 @@ pub(crate) fn why( let pm_decision = pm_decision_for_selected(ctx, overrides, selected); - let report = build_report( - task, - &candidates, - selected, - pm_decision.as_ref(), - overrides, - ctx, - schema_version, - ); - - if json { + if json && schema_version >= 3 { + let report = build_report_v3( + task, + &candidates, + selected, + pm_decision.as_ref(), + overrides, + ctx, + schema_version, + ); + println!("{}", serde_json::to_string_pretty(&report)?); + } else if json { + let report = build_report( + task, + &candidates, + selected, + pm_decision.as_ref(), + overrides, + ctx, + schema_version, + ); println!("{}", serde_json::to_string_pretty(&report)?); } else { print_human( @@ -233,6 +243,327 @@ fn candidate_json<'a>( } } +/// `runner why --json --schema-version 3` payload. Field order mirrors +/// the committed `schemas/why.v3.example.json`. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +pub(super) struct WhyReportV3<'a> { + #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] + #[cfg_attr( + feature = "schema", + schemars(description = "URI of the JSON Schema that describes this payload.") + )] + schema: String, + #[cfg_attr( + feature = "schema", + schemars(description = "Schema contract version for this JSON payload.") + )] + schema_version: u32, + #[cfg_attr( + feature = "schema", + schemars(description = "Payload discriminator; always \"runner.why\".") + )] + kind: &'static str, + #[cfg_attr( + feature = "schema", + schemars(description = "Project root the query ran against.") + )] + root: String, + #[cfg_attr( + feature = "schema", + schemars(description = "The task selector as the user typed it.") + )] + query: &'a str, + pm_resolution: Option, + selected: Option>, + candidates: Vec>, + decision: WhyDecisionV3, +} + +/// One candidate: the task's identity plus how it matched the query. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct WhyCandidateV3<'a> { + task: WhyTaskV3<'a>, + #[serde(rename = "match")] + matched: WhyMatchV3<'a>, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct WhyTaskV3<'a> { + name: &'a str, + #[cfg_attr( + feature = "schema", + schemars( + description = "Stable task identity: `::`. Scope is `root` until workspace-member scoping lands." + ) + )] + fqn: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "Tool family that would execute the task (e.g. `cargo`, `just`, `node`)." + ) + )] + provider: &'static str, + #[cfg_attr( + feature = "schema", + schemars(description = "Task mechanism label (v3 source label, e.g. `cargo-alias`).") + )] + kind: &'static str, + #[cfg_attr( + feature = "schema", + schemars(description = "Config file the task was extracted from, when resolvable.") + )] + source: Option, + #[cfg_attr( + feature = "schema", + schemars( + description = "Locator inside the source file: a key path for structured configs (`alias.t`, `scripts.test`), the target/recipe name for flat files." + ) + )] + source_pointer: Option, + description: Option<&'a str>, + #[cfg_attr( + feature = "schema", + schemars(description = "Names of sibling alias tasks that resolve to this task.") + )] + aliases: Vec<&'a str>, + #[cfg_attr( + feature = "schema", + schemars( + description = "Raw definition target: alias expansion or tool-specific run target." + ) + )] + definition: Option<&'a str>, + #[cfg_attr( + feature = "schema", + schemars( + description = "Effective command preview. Null when it depends on a PM resolution that was not performed for this candidate." + ) + )] + resolved: Option, + cwd: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "Task dependencies. Always empty today: no extractor records dependency edges yet." + ) + )] + dependencies: Vec, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct WhyMatchV3<'a> { + selector: &'a str, + #[cfg_attr( + feature = "schema", + schemars(description = "How the selector matched. `why` matches exact names only today.") + )] + matched_by: &'static str, + depth: Option, + display_order: u8, + source_priority: u16, + is_alias: bool, + passthrough_to: Option<&'static str>, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct WhyDecisionV3 { + #[cfg_attr( + feature = "schema", + schemars( + description = "Selection branch taken: `single-candidate`, `ranked`, `filtered`, or `exec-fallback`." + ) + )] + strategy: &'static str, + reason: String, +} + +fn build_report_v3<'a>( + query: &'a str, + candidates: &[&'a Task], + selected: Option<&'a Task>, + pm_decision: Option<&PmDecision>, + overrides: &ResolutionOverrides, + ctx: &'a ProjectContext, + schema_version: u32, +) -> WhyReportV3<'a> { + let candidate_v3 = |task: &'a Task| WhyCandidateV3 { + task: task_v3(task, ctx, pm_decision, selected, schema_version), + matched: match_v3(query, task, overrides, ctx), + }; + WhyReportV3 { + schema: String::new(), + schema_version, + kind: "runner.why", + root: ctx.root.display().to_string(), + query, + pm_resolution: pm_decision.map(pm_resolution), + selected: selected.map(candidate_v3), + candidates: candidates.iter().copied().map(candidate_v3).collect(), + decision: decision_v3(candidates, selected), + } +} + +fn task_v3<'a>( + task: &'a Task, + ctx: &'a ProjectContext, + pm_decision: Option<&PmDecision>, + selected: Option<&Task>, + schema_version: u32, +) -> WhyTaskV3<'a> { + let kind = crate::schema::labels::source_label_for(task.source, schema_version); + let is_selected = selected.is_some_and(|sel| std::ptr::eq(sel, task)); + WhyTaskV3 { + name: &task.name, + fqn: format!("root:{kind}:{name}", name = task.name), + provider: provider_label(task.source), + kind, + source: source_dir_for_task(task, ctx).map(|path| path.display().to_string()), + source_pointer: source_pointer(task), + description: task.description.as_deref(), + aliases: ctx + .tasks + .iter() + .filter(|other| { + other.source == task.source && other.alias_of.as_deref() == Some(&task.name) + }) + .map(|other| other.name.as_str()) + .collect(), + definition: task.alias_of.as_deref().or(task.run_target.as_deref()), + resolved: resolved_command(task, pm_decision.filter(|_| is_selected)), + cwd: ctx.root.display().to_string(), + dependencies: Vec::new(), + } +} + +fn match_v3<'a>( + selector: &'a str, + task: &Task, + overrides: &ResolutionOverrides, + ctx: &ProjectContext, +) -> WhyMatchV3<'a> { + let depth = source_depth(ctx, task.source); + WhyMatchV3 { + selector, + matched_by: "name", + depth: (depth != usize::MAX).then_some(depth), + display_order: task.source.display_order(), + source_priority: source_priority(overrides, task.source), + is_alias: task.alias_of.is_some(), + passthrough_to: task.passthrough_to.map(crate::types::TaskRunner::label), + } +} + +fn decision_v3(candidates: &[&Task], selected: Option<&Task>) -> WhyDecisionV3 { + if candidates.is_empty() { + return WhyDecisionV3 { + strategy: "exec-fallback", + reason: "no task matched; `runner run` would route the name through the primary \ + package manager's exec primitive" + .to_string(), + }; + } + if selected.is_none() { + return WhyDecisionV3 { + strategy: "filtered", + reason: "every candidate was filtered out by --runner/RUNNER_RUNNER restrictions" + .to_string(), + }; + } + if candidates.len() == 1 { + return WhyDecisionV3 { + strategy: "single-candidate", + reason: "exact task name matched one candidate".to_string(), + }; + } + WhyDecisionV3 { + strategy: "ranked", + reason: format!( + "{} candidates; lowest (source_priority, source_depth, display_order, alias-last) \ + key wins", + candidates.len() + ), + } +} + +/// Tool family that executes tasks from this source. Distinct from the +/// v3 `kind` label, which names the extraction mechanism. +const fn provider_label(source: TaskSource) -> &'static str { + match source { + TaskSource::PackageJson => "node", + TaskSource::DenoJson => "deno", + TaskSource::TurboJson => "turbo", + TaskSource::Makefile => "make", + TaskSource::Justfile => "just", + TaskSource::Taskfile => "task", + TaskSource::CargoAliases => "cargo", + TaskSource::GoPackage => "go", + TaskSource::BaconToml => "bacon", + TaskSource::MiseToml => "mise", + TaskSource::PyprojectScripts => "python", + } +} + +/// Key path (structured configs) or target name (flat files) locating +/// the task inside its source file. +fn source_pointer(task: &Task) -> Option { + let name = &task.name; + match task.source { + TaskSource::CargoAliases => Some(format!("alias.{name}")), + TaskSource::PackageJson => Some(format!("scripts.{name}")), + TaskSource::DenoJson + | TaskSource::TurboJson + | TaskSource::Taskfile + | TaskSource::MiseToml => Some(format!("tasks.{name}")), + TaskSource::BaconToml => Some(format!("jobs.{name}")), + TaskSource::PyprojectScripts => Some(format!("project.scripts.{name}")), + TaskSource::Makefile | TaskSource::Justfile => Some(name.clone()), + TaskSource::GoPackage => None, + } +} + +/// Effective command preview for the candidate. Sources with a fixed +/// executing binary render deterministically; `package.json` and +/// `pyproject.toml` scripts depend on PM resolution, which `why` only +/// performs for the selected task — other candidates report null. +fn resolved_command(task: &Task, pm_decision: Option<&PmDecision>) -> Option { + let name = &task.name; + match task.source { + TaskSource::CargoAliases => Some(task.alias_of.as_deref().map_or_else( + || format!("cargo {name}"), + |expansion| format!("cargo {expansion}"), + )), + TaskSource::DenoJson => Some(format!("deno task {name}")), + TaskSource::TurboJson => Some(format!("turbo run {name}")), + TaskSource::Makefile => Some(format!("make {name}")), + TaskSource::Justfile => Some(format!("just {name}")), + TaskSource::Taskfile => Some(format!("task {name}")), + TaskSource::BaconToml => Some(format!("bacon {name}")), + TaskSource::MiseToml => Some(format!("mise run {name}")), + TaskSource::GoPackage => Some(format!( + "go run {target}", + target = task.run_target.as_deref().unwrap_or(name) + )), + TaskSource::PackageJson => match pm_decision { + Some(PmDecision::Node(Ok(decision))) => { + Some(format!("{pm} run {name}", pm = decision.pm.label())) + } + _ => None, + }, + TaskSource::PyprojectScripts => match pm_decision { + Some(PmDecision::Python(Ok(decision))) => { + Some(format!("{pm} run {name}", pm = decision.pm.label())) + } + _ => None, + }, + } +} + fn source_dir_for_task(task: &Task, ctx: &ProjectContext) -> Option { use crate::tool; @@ -340,7 +671,7 @@ fn print_human( mod tests { use std::path::PathBuf; - use super::{PmDecision, build_report, pm_decision_for_selected, why}; + use super::{PmDecision, build_report, build_report_v3, pm_decision_for_selected, why}; use crate::resolver::{DiagnosticFlags, ResolutionOverrides}; use crate::types::{PackageManager, ProjectContext, Task, TaskSource}; @@ -459,6 +790,153 @@ mod tests { ); } + #[test] + fn v3_report_describes_cargo_alias_like_the_committed_example() { + let mut alias = task("t", TaskSource::CargoAliases); + alias.alias_of = Some("test".to_string()); + let ctx = context(vec![alias]); + let candidates = vec![&ctx.tasks[0]]; + let selected = ctx.tasks.first(); + + let report = build_report_v3( + "t", + &candidates, + selected, + None, + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!(json["schema_version"], 3); + assert_eq!(json["kind"], "runner.why"); + assert_eq!(json["query"], "t"); + assert_eq!(json["pm_resolution"], serde_json::Value::Null); + + let task = &json["selected"]["task"]; + assert_eq!(task["name"], "t"); + assert_eq!(task["fqn"], "root:cargo-alias:t"); + assert_eq!(task["provider"], "cargo"); + assert_eq!(task["kind"], "cargo-alias"); + assert_eq!(task["source_pointer"], "alias.t"); + assert_eq!(task["definition"], "test"); + assert_eq!(task["resolved"], "cargo test"); + assert_eq!(task["dependencies"], serde_json::json!([])); + + let matched = &json["selected"]["match"]; + assert_eq!(matched["selector"], "t"); + assert_eq!(matched["matched_by"], "name"); + assert_eq!(matched["is_alias"], true); + + assert_eq!(json["candidates"].as_array().map(Vec::len), Some(1)); + assert_eq!(json["decision"]["strategy"], "single-candidate"); + assert_eq!( + json["decision"]["reason"], + "exact task name matched one candidate" + ); + } + + #[test] + fn v3_report_uses_exec_fallback_decision_when_nothing_matches() { + let ctx = context(vec![]); + let report = build_report_v3( + "nope", + &[], + None, + None, + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!(json["selected"], serde_json::Value::Null); + assert_eq!(json["candidates"], serde_json::json!([])); + assert_eq!(json["decision"]["strategy"], "exec-fallback"); + } + + #[test] + fn v3_report_ranks_multiple_candidates() { + let ctx = context(vec![ + task("build", TaskSource::PackageJson), + task("build", TaskSource::Justfile), + ]); + let candidates: Vec<&Task> = ctx.tasks.iter().collect(); + let report = build_report_v3( + "build", + &candidates, + ctx.tasks.first(), + None, + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!(json["decision"]["strategy"], "ranked"); + assert_eq!(json["candidates"].as_array().map(Vec::len), Some(2)); + // package.json resolved depends on PM resolution, which only the + // selected task gets — and no PM decision was passed here. + assert_eq!( + json["candidates"][0]["task"]["resolved"], + serde_json::Value::Null + ); + assert_eq!(json["candidates"][1]["task"]["resolved"], "just build"); + } + + #[test] + fn v3_report_resolves_selected_pyproject_script_through_python_pm() { + let mut ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]); + ctx.package_managers.push(PackageManager::Uv); + let selected = ctx.tasks.first(); + let pm_decision = pm_decision_for_selected(&ctx, &ResolutionOverrides::default(), selected) + .expect("pyproject task should resolve PM diagnostics"); + let candidates = vec![&ctx.tasks[0]]; + + let report = build_report_v3( + "greenpy", + &candidates, + selected, + Some(&pm_decision), + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!(json["selected"]["task"]["provider"], "python"); + assert_eq!(json["selected"]["task"]["resolved"], "uv run greenpy"); + assert_eq!( + json["selected"]["task"]["source_pointer"], + "project.scripts.greenpy" + ); + } + + #[test] + fn v3_report_collects_sibling_aliases() { + let mut shortcut = task("f", TaskSource::Justfile); + shortcut.alias_of = Some("fmt".to_string()); + let ctx = context(vec![task("fmt", TaskSource::Justfile), shortcut]); + let candidates = vec![&ctx.tasks[0]]; + + let report = build_report_v3( + "fmt", + &candidates, + ctx.tasks.first(), + None, + &ResolutionOverrides::default(), + &ctx, + 3, + ); + let json = serde_json::to_value(&report).expect("v3 report should serialize"); + + assert_eq!( + json["selected"]["task"]["aliases"], + serde_json::json!(["f"]) + ); + } + #[test] fn why_pyproject_script_reports_python_pm_override() { let ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]); diff --git a/src/lib.rs b/src/lib.rs index 729e45f..ced950b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -583,6 +583,28 @@ fn schema_version_for_json(json: bool, requested: Option) -> Result { } } +/// `why`-specific version resolution: `why` is at +/// [`schema::WHY_CURRENT_VERSION`] while list remains at +/// [`schema::CURRENT_VERSION`], so it validates against its own range +/// and defaults to its own latest. +fn why_schema_version_for_json(json: bool, requested: Option) -> Result { + if json { + schema::validate_why_schema_version(requested.unwrap_or(schema::WHY_CURRENT_VERSION)) + } else { + Ok(schema::WHY_CURRENT_VERSION) + } +} + +/// `doctor`-specific version resolution; see +/// [`schema::DOCTOR_CURRENT_VERSION`]. +fn doctor_schema_version_for_json(json: bool, requested: Option) -> Result { + if json { + schema::validate_doctor_schema_version(requested.unwrap_or(schema::DOCTOR_CURRENT_VERSION)) + } else { + Ok(schema::DOCTOR_CURRENT_VERSION) + } +} + /// Build [`resolver::ResolutionOverrides`] from a parsed CLI + loaded config. /// Lifted out of [`dispatch`] so the latter stays under clippy's /// `too_many_lines` budget; the chain-failure inputs come from whichever @@ -767,12 +789,12 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { #[cfg(feature = "schema")] Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()), Some(cli::Command::Doctor { json }) => { - let schema_version = schema_version_for_json(json, cli.global.schema_version)?; + let schema_version = doctor_schema_version_for_json(json, cli.global.schema_version)?; cmd::doctor(&ctx, &overrides, json, schema_version)?; Ok(0) } Some(cli::Command::Why { task, json }) => { - let schema_version = schema_version_for_json(json, cli.global.schema_version)?; + let schema_version = why_schema_version_for_json(json, cli.global.schema_version)?; cmd::why(&ctx, &overrides, &task, json, schema_version)?; Ok(0) } diff --git a/src/resolver/mod.rs b/src/resolver/mod.rs index 665d699..9a24613 100644 --- a/src/resolver/mod.rs +++ b/src/resolver/mod.rs @@ -49,7 +49,7 @@ pub(crate) use probe::probe_in as probe_path_for_doctor; pub(crate) use types::PmOverride; pub(crate) use types::{ DiagnosticFlags, FallbackPolicy, MismatchPolicy, OverrideOrigin, ResolutionOverrides, - ResolvedPm, Resolver, + ResolutionStep, ResolvedPm, Resolver, }; /// Join an iterator of `&'static str` labels with `", "`. Used by the diff --git a/src/schema/doctor_v3.rs b/src/schema/doctor_v3.rs new file mode 100644 index 0000000..a579943 --- /dev/null +++ b/src/schema/doctor_v3.rs @@ -0,0 +1,1114 @@ +//! `doctor --json` schema **v3** — the structured diagnostic report. +//! +//! Implements the contract drafted in `schemas/doctor.v3-draft.schema.json` +//! (now retired): instead of v2's flat detection dump, the report is an +//! inventory — `invocation`/`environment`/`runner` provenance, per-ecosystem +//! decisions with confidence, task `sources` as first-class objects, tasks +//! with stable `fqn`s, PATH-probe `tools`, duplicate-name `conflicts`, and +//! flattened `diagnostics` — plus a self-describing `resolution` policy +//! block. +//! +//! Deliberate deltas from the draft, found while reviewing it against the +//! codebase: +//! +//! - `tasks[].resolved` and `tasks[].source` are nullable: a +//! `package.json` script's command depends on PM resolution, which can +//! fail, and a source anchor file can be undiscoverable. The draft +//! required both non-null; lying was the alternative. +//! - `sources[].kind` uses the v3 source labels (`cargo-alias`, `just`, +//! …) for cross-surface consistency with `why` v3, not the draft's +//! filename-flavored examples (`cargo-config`, `justfile`). +//! - `overrides.pm`/`overrides.runner` are bare labels (per draft); the +//! provenance (`cli`/`env`/`config:…`) remains available on the v2 +//! surface. +//! - `project.workspace` is always `null` and `project.root_source` is +//! the root itself until workspace/root-anchor detection is modeled. +//! - Speculative draft shapes nothing can emit yet are deferred rather +//! than declared: the rich `dependency` object (`tasks[].dependencies` +//! stays an always-empty array), `workspace`/`package_identity` +//! objects (fields stay null), the `tool_probe_error` variant (the +//! probe cannot error), the `binary`/`package-binary` tool kinds, and +//! the `debug`/`error` severities. Each gets declared when an +//! emitter exists — contracts should describe output, not ambition. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::Serialize; + +use super::labels::source_label_for; +use crate::cmd::run::{resolve_python_pm, select_task_entry, source_depth, source_priority}; +use crate::resolver::{ + FallbackPolicy, MismatchPolicy, ResolutionOverrides, ResolutionStep, Resolver, +}; +use crate::tool::node::detect_pm_from_manifest; +use crate::types::{DetectionWarning, Ecosystem, PackageManager, ProjectContext, Task, TaskSource}; + +/// `runner doctor --json --schema-version 3` payload. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +pub(crate) struct DoctorReportV3<'a> { + #[serde(rename = "$schema")] + #[cfg_attr( + feature = "schema", + schemars(description = "URI of the JSON Schema that describes this payload.") + )] + schema: String, + #[cfg_attr( + feature = "schema", + schemars(description = "Schema contract version for this JSON payload.") + )] + schema_version: u32, + #[cfg_attr( + feature = "schema", + schemars(description = "Payload discriminator; always \"runner.doctor\".") + )] + kind: &'static str, + invocation: InvocationV3, + environment: EnvironmentV3, + runner: RunnerInfoV3, + project: ProjectInfoV3, + overrides: OverridesV3, + ecosystems: Vec, + sources: Vec, + tasks: Vec>, + tools: Vec, + conflicts: Vec, + diagnostics: Vec, + resolution: ResolutionPolicyV3, +} + +/// How this report came to be: the exact process invocation. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct InvocationV3 { + argv: Vec, + cwd: String, + #[cfg_attr( + feature = "schema", + schemars(description = "UTC RFC 3339 timestamp of report generation.") + )] + started_at: String, +} + +/// Host facts that influence probing and dispatch. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct EnvironmentV3 { + arch: &'static str, + os: &'static str, + path_entries: Vec, + shell: Option, +} + +/// The reporting binary's own identity and contract versions. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct RunnerInfoV3 { + binary: String, + name: String, + version: &'static str, + schema_versions: SchemaVersionsV3, +} + +/// Latest schema version each `--json` surface speaks. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct SchemaVersionsV3 { + doctor: u32, + list: u32, + why: u32, +} + +/// Project anchoring facts. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct ProjectInfoV3 { + monorepo: bool, + root: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "What anchored root detection. Currently always the root itself (cwd or --dir); a dedicated anchor model is future work." + ) + )] + root_source: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "Workspace identity. Always null today: workspace kind/root detection is not yet modeled (the monorepo flag is the coarse signal)." + ) + )] + workspace: Option, +} + +/// Effective override stack, labels only. Provenance (cli/env/config) +/// stays on the v2 surface. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct OverridesV3 { + explain: bool, + fallback: &'static str, + no_warnings: bool, + on_mismatch: &'static str, + pm: Option<&'static str>, + pm_by_ecosystem: BTreeMap>, + prefer_runners: Vec<&'static str>, + runner: Option<&'static str>, +} + +/// One detected ecosystem and the PM decision made for it. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct EcosystemV3 { + decision: EcosystemDecisionV3, + name: &'static str, + root: String, + selected_package_manager: Option<&'static str>, + #[cfg_attr( + feature = "schema", + schemars( + description = "Detection evidence. Node carries the full signal set (lockfile/manifest/PATH probe/shim classification, keyed by tool with the shim manager as data); other ecosystems list their detected package managers." + ) + )] + signals: serde_json::Value, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct EcosystemDecisionV3 { + confidence: ConfidenceV3, + reason: String, + selected: Option<&'static str>, +} + +/// How sure the resolver is about an ecosystem's PM selection. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[serde(rename_all = "lowercase")] +enum ConfidenceV3 { + /// Explicit signal: override, manifest declaration, or lockfile. + High, + /// Inferred: PATH probe found a usable binary. + Medium, + /// Legacy `--fallback npm` default with no signal at all. + Low, + /// Resolution failed. + None, +} + +/// One task-source config file as a first-class object. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct SourceV3 { + exists: bool, + #[cfg_attr( + feature = "schema", + schemars(description = "Stable source identity: `src::`.") + )] + id: String, + #[cfg_attr( + feature = "schema", + schemars(description = "v3 source label (same convention as `why` v3).") + )] + kind: &'static str, + #[cfg_attr( + feature = "schema", + schemars(description = "Package identity for manifest-backed sources. Null today.") + )] + package: Option, + path: String, + relpath: String, + #[cfg_attr( + feature = "schema", + schemars(description = "Project-root-relative scope; `root` until member scoping lands.") + )] + scope: &'static str, + #[cfg_attr( + feature = "schema", + schemars( + description = "Key of the container holding tasks inside the file (`scripts`, `tasks`, `alias`, …); null for flat-format files." + ) + )] + task_pointer: Option<&'static str>, +} + +/// One task in the doctor inventory. Same identity scheme as `why` v3 +/// (`fqn`, `source_pointer`, `aliases`, `definition`, `resolved`). +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct DoctorTaskV3<'a> { + aliases: Vec<&'a str>, + cwd: String, + definition: Option<&'a str>, + #[cfg_attr( + feature = "schema", + schemars( + description = "Task dependencies. Always empty today: no extractor records dependency edges yet; the edge shape lands with the first extractor." + ) + )] + dependencies: Vec, + description: Option<&'a str>, + fqn: String, + name: &'a str, + #[cfg_attr( + feature = "schema", + schemars( + description = "Effective command preview. Null when it depends on a PM resolution that failed." + ) + )] + resolved: Option, + source: Option, + source_pointer: Option, +} + +/// What kind of thing a probed tool is. The draft's `binary` / +/// `package-binary` kinds join when something probes them. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +enum DependencyKindV3 { + Runtime, + PackageManager, + TaskRunner, +} + +impl DependencyKindV3 { + const fn label(self) -> &'static str { + match self { + Self::Runtime => "runtime", + Self::PackageManager => "package-manager", + Self::TaskRunner => "task-runner", + } + } +} + +/// One PATH-probed tool the project relies on. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct ToolV3 { + #[cfg_attr( + feature = "schema", + schemars(description = "Stable tool identity: `tool::`.") + )] + id: String, + kind: DependencyKindV3, + name: &'static str, + probe: ToolProbeV3, + required: bool, +} + +/// PATH-probe outcome, tagged by `status`. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[serde(tag = "status", rename_all = "lowercase")] +enum ToolProbeV3 { + Found { + path: String, + #[cfg_attr( + feature = "schema", + schemars( + description = "Version when already known from detection (probing never executes binaries)." + ) + )] + version: Option, + }, + Missing, +} + +/// A task name claimed by more than one source: who wins, who is shadowed. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct ConflictV3 { + kind: &'static str, + reason: String, + #[cfg_attr(feature = "schema", schemars(description = "FQN of the winning task."))] + selected: String, + selector: String, + severity: SeverityV3, + shadowed: Vec, +} + +/// Severity of a conflict or diagnostic. The draft's `debug`/`error` +/// levels join when something emits them. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +enum SeverityV3 { + Info, + Warning, +} + +/// One detection/resolution diagnostic, flattened from the warning +/// streams. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct DiagnosticV3 { + #[cfg_attr( + feature = "schema", + schemars(description = "Stable warning category (the warning's source subsystem).") + )] + code: &'static str, + message: String, + severity: SeverityV3, + source: Option<&'static str>, + task: Option, +} + +/// Self-description of the task-selection policy, so consumers don't +/// hardcode runner's precedence rules. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct ResolutionPolicyV3 { + fqn_policy: &'static str, + precedence: Vec<&'static str>, + short_name_policy: &'static str, +} + +impl<'a> DoctorReportV3<'a> { + /// Build the v3 report. `resolve_shims` is forwarded to the Volta + /// shim classifier exactly like the v2 builder. + pub(crate) fn build( + ctx: &'a ProjectContext, + overrides: &ResolutionOverrides, + resolve_shims: bool, + ) -> Self { + let node_pm = Resolver::new(ctx, overrides).resolve_node_pm(); + let schema_version = super::DOCTOR_CURRENT_VERSION; + + let diagnostics = ctx + .warnings + .iter() + .chain(node_pm.as_ref().map_or(&[][..], |d| &d.warnings)) + .map(diagnostic_v3) + .collect(); + + Self { + schema: super::schema_url("doctor", schema_version), + schema_version, + kind: "runner.doctor", + invocation: invocation_v3(), + environment: environment_v3(), + runner: runner_info_v3(), + project: ProjectInfoV3 { + monorepo: ctx.is_monorepo, + root: ctx.root.display().to_string(), + root_source: ctx.root.display().to_string(), + workspace: None, + }, + overrides: overrides_v3(overrides), + ecosystems: ecosystems_v3(ctx, overrides, &node_pm, resolve_shims), + sources: sources_v3(ctx, schema_version), + tasks: tasks_v3(ctx, &node_pm, overrides, schema_version), + tools: tools_v3(ctx), + conflicts: conflicts_v3(ctx, overrides, schema_version), + diagnostics, + resolution: ResolutionPolicyV3 { + fqn_policy: "exact-only", + precedence: vec![ + "source-priority", + "source-depth", + "display-order", + "alias-last", + ], + short_name_policy: "deterministic-precedence", + }, + } + } +} + +fn invocation_v3() -> InvocationV3 { + InvocationV3 { + argv: std::env::args().collect(), + cwd: std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(), + started_at: rfc3339_utc_now(), + } +} + +fn environment_v3() -> EnvironmentV3 { + EnvironmentV3 { + arch: std::env::consts::ARCH, + os: std::env::consts::OS, + path_entries: std::env::var_os("PATH") + .map(|path| { + std::env::split_paths(&path) + .map(|entry| entry.display().to_string()) + .collect() + }) + .unwrap_or_default(), + shell: std::env::var("SHELL").ok(), + } +} + +fn runner_info_v3() -> RunnerInfoV3 { + let binary = std::env::current_exe() + .map_or_else(|_| "runner".to_string(), |exe| exe.display().to_string()); + let name = std::env::args_os() + .next() + .and_then(|arg0| crate::bin_name_from_arg0(&arg0)) + .unwrap_or_else(|| "runner".to_string()); + RunnerInfoV3 { + binary, + name, + version: env!("CARGO_PKG_VERSION"), + schema_versions: SchemaVersionsV3 { + doctor: super::DOCTOR_CURRENT_VERSION, + list: super::CURRENT_VERSION, + why: super::WHY_CURRENT_VERSION, + }, + } +} + +fn overrides_v3(overrides: &ResolutionOverrides) -> OverridesV3 { + OverridesV3 { + explain: overrides.explain, + fallback: match overrides.fallback { + FallbackPolicy::Probe => "probe", + FallbackPolicy::Npm => "npm", + FallbackPolicy::Error => "error", + }, + no_warnings: overrides.no_warnings, + on_mismatch: match overrides.on_mismatch { + MismatchPolicy::Warn => "warn", + MismatchPolicy::Error => "error", + MismatchPolicy::Ignore => "ignore", + }, + pm: overrides.pm.as_ref().map(|o| o.pm.label()), + pm_by_ecosystem: overrides + .pm_by_ecosystem + .iter() + .map(|(eco, o)| (eco.label().to_string(), Some(o.pm.label()))) + .collect(), + prefer_runners: overrides.prefer_runners.iter().map(|r| r.label()).collect(), + runner: overrides.runner.as_ref().map(|o| o.runner.label()), + } +} + +fn ecosystems_v3( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, + node_pm: &Result, + resolve_shims: bool, +) -> Vec { + let mut seen = Vec::new(); + for pm in &ctx.package_managers { + let eco = pm.ecosystem(); + if !seen.contains(&eco) { + seen.push(eco); + } + } + + seen.into_iter() + .map(|eco| match eco { + Ecosystem::Node => node_ecosystem_v3(ctx, node_pm, resolve_shims), + Ecosystem::Python => python_ecosystem_v3(ctx, overrides), + other => single_pm_ecosystem_v3(ctx, other), + }) + .collect() +} + +fn node_ecosystem_v3( + ctx: &ProjectContext, + node_pm: &Result, + resolve_shims: bool, +) -> EcosystemV3 { + let (decision, selected) = match node_pm { + Ok(decision) => ( + EcosystemDecisionV3 { + confidence: confidence_for_step(&decision.via), + reason: decision.describe(), + selected: Some(decision.pm.label()), + }, + Some(decision.pm.label()), + ), + Err(err) => ( + EcosystemDecisionV3 { + confidence: ConfidenceV3::None, + reason: format!("{err}"), + selected: None, + }, + None, + ), + }; + + let manifest_decl = detect_pm_from_manifest(&ctx.root); + let probes = super::project::probe_signals(&ctx.root, resolve_shims); + // Shims are keyed by tool and carry the shim *manager* as data, not + // as the field name — Volta is merely the first manager the prober + // classifies; asdf/mise/proto entries slot in without a contract + // change. (v2's `volta_shims` spelling is frozen; only v3 gets the + // generic shape.) + let shims = probes + .volta_shims + .iter() + .map(|(name, shim)| { + ( + (*name).to_string(), + serde_json::json!({ "manager": "volta", "resolved": shim.resolved }), + ) + }) + .collect::>(); + let signals = serde_json::json!({ + "lockfile_pm": ctx.primary_node_pm().map(PackageManager::label), + "manifest_pm": manifest_decl.as_ref().map(|d| d.pm.label()), + "path_probe": probes.path_probe, + "shims": shims, + }); + + EcosystemV3 { + decision, + name: "node", + root: ctx.root.display().to_string(), + selected_package_manager: selected, + signals, + } +} + +fn python_ecosystem_v3(ctx: &ProjectContext, overrides: &ResolutionOverrides) -> EcosystemV3 { + let resolved = resolve_python_pm(ctx, overrides); + let (decision, selected) = resolved.map_or_else( + || { + ( + EcosystemDecisionV3 { + confidence: ConfidenceV3::None, + reason: "no Python package manager detected".to_string(), + selected: None, + }, + None, + ) + }, + |decision| { + let label = decision.pm.label(); + ( + EcosystemDecisionV3 { + confidence: ConfidenceV3::High, + reason: decision.describe(), + selected: Some(label), + }, + Some(label), + ) + }, + ); + + EcosystemV3 { + decision, + name: "python", + root: ctx.root.display().to_string(), + selected_package_manager: selected, + signals: detected_pm_signals(ctx, Ecosystem::Python), + } +} + +/// Single-PM ecosystems (rust/go/deno/ruby/php): the detected manager +/// *is* the decision — there is no competing-PM resolution chain. +fn single_pm_ecosystem_v3(ctx: &ProjectContext, eco: Ecosystem) -> EcosystemV3 { + let selected = ctx + .package_managers + .iter() + .find(|pm| pm.ecosystem() == eco) + .map(|pm| pm.label()); + + EcosystemV3 { + decision: EcosystemDecisionV3 { + confidence: ConfidenceV3::High, + reason: format!( + "detected via {} project signal", + selected.unwrap_or("manifest") + ), + selected, + }, + name: eco.label(), + root: ctx.root.display().to_string(), + selected_package_manager: selected, + signals: detected_pm_signals(ctx, eco), + } +} + +fn detected_pm_signals(ctx: &ProjectContext, eco: Ecosystem) -> serde_json::Value { + serde_json::json!({ + "package_managers": ctx + .package_managers + .iter() + .filter(|pm| pm.ecosystem() == eco) + .map(|pm| pm.label()) + .collect::>(), + }) +} + +const fn confidence_for_step(step: &ResolutionStep) -> ConfidenceV3 { + match step { + ResolutionStep::Override(_) + | ResolutionStep::ManifestPackageManager + | ResolutionStep::ManifestDevEngines { .. } + | ResolutionStep::Lockfile => ConfidenceV3::High, + ResolutionStep::PathProbe { .. } => ConfidenceV3::Medium, + ResolutionStep::LegacyNpmFallback => ConfidenceV3::Low, + } +} + +fn sources_v3(ctx: &ProjectContext, schema_version: u32) -> Vec { + let mut seen: Vec = Vec::new(); + for task in &ctx.tasks { + if !seen.contains(&task.source) { + seen.push(task.source); + } + } + + seen.into_iter() + .map(|source| { + let kind = source_label_for(source, schema_version); + let anchor = anchor_file(source, &ctx.root); + let path = anchor + .as_ref() + .map_or_else(String::new, |p| p.display().to_string()); + let relpath = anchor.as_ref().map_or_else(String::new, |p| { + p.strip_prefix(&ctx.root).unwrap_or(p).display().to_string() + }); + SourceV3 { + exists: anchor.as_ref().is_some_and(|p| p.is_file()), + id: format!("src:root:{kind}"), + kind, + package: None, + path, + relpath, + scope: "root", + task_pointer: task_container_key(source), + } + }) + .collect() +} + +fn tasks_v3<'a>( + ctx: &'a ProjectContext, + node_pm: &Result, + overrides: &ResolutionOverrides, + schema_version: u32, +) -> Vec> { + let node_pm_label = node_pm.as_ref().ok().map(|d| d.pm.label()); + let python_pm_label = resolve_python_pm(ctx, overrides).map(|d| d.pm.label()); + + // `anchor_file` walks the filesystem; resolve each distinct source + // once instead of once per task. + let mut anchors: std::collections::HashMap> = + std::collections::HashMap::new(); + for task in &ctx.tasks { + anchors.entry(task.source).or_insert_with(|| { + anchor_file(task.source, &ctx.root).map(|p| p.display().to_string()) + }); + } + + ctx.tasks + .iter() + .map(|task| { + let kind = source_label_for(task.source, schema_version); + DoctorTaskV3 { + aliases: ctx + .tasks + .iter() + .filter(|other| { + other.source == task.source && other.alias_of.as_deref() == Some(&task.name) + }) + .map(|other| other.name.as_str()) + .collect(), + cwd: ctx.root.display().to_string(), + definition: task.alias_of.as_deref().or(task.run_target.as_deref()), + dependencies: Vec::new(), + description: task.description.as_deref(), + fqn: format!("root:{kind}:{name}", name = task.name), + name: &task.name, + resolved: resolved_command_v3(task, node_pm_label, python_pm_label), + source: anchors.get(&task.source).cloned().flatten(), + source_pointer: source_pointer_v3(task), + } + }) + .collect() +} + +/// Effective command preview. Unlike `why` v3 (which only resolves the +/// PM for the selected task), doctor resolves PMs project-wide, so +/// `package.json`/`pyproject.toml` scripts resolve here whenever the +/// ecosystem resolution succeeded. +fn resolved_command_v3( + task: &Task, + node_pm: Option<&'static str>, + python_pm: Option<&'static str>, +) -> Option { + let name = &task.name; + match task.source { + TaskSource::CargoAliases => Some(task.alias_of.as_deref().map_or_else( + || format!("cargo {name}"), + |expansion| format!("cargo {expansion}"), + )), + TaskSource::DenoJson => Some(format!("deno task {name}")), + TaskSource::TurboJson => Some(format!("turbo run {name}")), + TaskSource::Makefile => Some(format!("make {name}")), + TaskSource::Justfile => Some(format!("just {name}")), + TaskSource::Taskfile => Some(format!("task {name}")), + TaskSource::BaconToml => Some(format!("bacon {name}")), + TaskSource::MiseToml => Some(format!("mise run {name}")), + TaskSource::GoPackage => Some(format!( + "go run {target}", + target = task.run_target.as_deref().unwrap_or(name) + )), + TaskSource::PackageJson => node_pm.map(|pm| format!("{pm} run {name}")), + TaskSource::PyprojectScripts => python_pm.map(|pm| format!("{pm} run {name}")), + } +} + +/// Key path locating the task inside its source file; mirrors the +/// `why` v3 convention. +fn source_pointer_v3(task: &Task) -> Option { + let name = &task.name; + match task.source { + TaskSource::CargoAliases => Some(format!("alias.{name}")), + TaskSource::PackageJson => Some(format!("scripts.{name}")), + TaskSource::DenoJson + | TaskSource::TurboJson + | TaskSource::Taskfile + | TaskSource::MiseToml => Some(format!("tasks.{name}")), + TaskSource::BaconToml => Some(format!("jobs.{name}")), + TaskSource::PyprojectScripts => Some(format!("project.scripts.{name}")), + TaskSource::Makefile | TaskSource::Justfile => Some(name.clone()), + TaskSource::GoPackage => None, + } +} + +/// Container key holding tasks inside the source file. +const fn task_container_key(source: TaskSource) -> Option<&'static str> { + match source { + TaskSource::CargoAliases => Some("alias"), + TaskSource::PackageJson => Some("scripts"), + TaskSource::DenoJson + | TaskSource::TurboJson + | TaskSource::Taskfile + | TaskSource::MiseToml => Some("tasks"), + TaskSource::BaconToml => Some("jobs"), + TaskSource::PyprojectScripts => Some("project.scripts"), + TaskSource::Makefile | TaskSource::Justfile | TaskSource::GoPackage => None, + } +} + +/// Config file anchoring a task source. Mirrors `cmd::why`'s anchor +/// walk (file paths, not parent dirs). +fn anchor_file(source: TaskSource, root: &Path) -> Option { + use crate::tool; + + match source { + TaskSource::PackageJson => tool::node::find_manifest_upwards(root), + TaskSource::DenoJson => tool::deno::find_config_upwards(root), + TaskSource::TurboJson => tool::turbo::find_config(root), + TaskSource::Makefile => tool::files::find_first(root, tool::make::FILENAMES), + TaskSource::Justfile => tool::just::find_file(root), + TaskSource::Taskfile => tool::files::find_first(root, tool::go_task::FILENAMES), + TaskSource::CargoAliases => tool::cargo_aliases::find_anchor(root), + TaskSource::GoPackage => tool::go_pm::find_file(root), + TaskSource::BaconToml => tool::files::find_first(root, tool::bacon::FILENAMES), + TaskSource::MiseToml => tool::mise::find_file(root), + TaskSource::PyprojectScripts => tool::python::find_pyproject_upwards(root), + } +} + +fn tools_v3(ctx: &ProjectContext) -> Vec { + let path = std::env::var_os("PATH").unwrap_or_default(); + let pathext = std::env::var_os("PATHEXT"); + let pathext_ref = pathext.as_deref(); + + let mut tools = Vec::new(); + + if ctx + .package_managers + .iter() + .any(|pm| pm.ecosystem() == Ecosystem::Node) + { + tools.push(probe_tool( + "node", + DependencyKindV3::Runtime, + ctx.current_node + .as_deref() + .map(|v| v.trim_start_matches('v').to_string()), + &path, + pathext_ref, + )); + } + + for pm in &ctx.package_managers { + tools.push(probe_tool( + pm_binary_name(*pm), + DependencyKindV3::PackageManager, + None, + &path, + pathext_ref, + )); + } + for runner in &ctx.task_runners { + tools.push(probe_tool( + runner.label(), + DependencyKindV3::TaskRunner, + None, + &path, + pathext_ref, + )); + } + + tools +} + +/// Binary actually probed for a PM. Labels and binaries coincide except +/// Bundler, whose CLI is `bundle`. +const fn pm_binary_name(pm: PackageManager) -> &'static str { + match pm { + PackageManager::Bundler => "bundle", + _ => pm.label(), + } +} + +fn probe_tool( + name: &'static str, + kind: DependencyKindV3, + version: Option, + path: &std::ffi::OsStr, + pathext: Option<&std::ffi::OsStr>, +) -> ToolV3 { + let probe = crate::resolver::probe_path_for_doctor(name, path, pathext).map_or( + ToolProbeV3::Missing, + |hit| ToolProbeV3::Found { + path: hit.display().to_string(), + version, + }, + ); + ToolV3 { + id: format!("tool:{kind}:{name}", kind = kind.label()), + kind, + name, + probe, + required: true, + } +} + +fn conflicts_v3( + ctx: &ProjectContext, + overrides: &ResolutionOverrides, + schema_version: u32, +) -> Vec { + let mut by_name: BTreeMap<&str, Vec<&Task>> = BTreeMap::new(); + for task in &ctx.tasks { + by_name.entry(&task.name).or_default().push(task); + } + + by_name + .into_iter() + .filter(|(_, group)| group.len() > 1) + .map(|(name, group)| { + let selected = select_task_entry(ctx, overrides, &group); + let fqn_of = |task: &Task| { + format!( + "root:{kind}:{name}", + kind = source_label_for(task.source, schema_version), + name = task.name + ) + }; + ConflictV3 { + kind: "duplicate-task-name", + reason: format!( + "{count} sources define `{name}`; lowest (source_priority={priority}, \ + source_depth={depth}, display_order={order}, alias-last) key wins", + count = group.len(), + priority = source_priority(overrides, selected.source), + depth = display_depth(source_depth(ctx, selected.source)), + order = selected.source.display_order(), + ), + selected: fqn_of(selected), + selector: name.to_string(), + severity: SeverityV3::Info, + shadowed: group + .iter() + .filter(|task| !std::ptr::eq(**task, selected)) + .map(|task| fqn_of(task)) + .collect(), + } + }) + .collect() +} + +fn display_depth(depth: usize) -> String { + if depth == usize::MAX { + "unresolved".to_string() + } else { + depth.to_string() + } +} + +fn diagnostic_v3(warning: &DetectionWarning) -> DiagnosticV3 { + DiagnosticV3 { + code: warning.source(), + message: warning.detail(), + severity: SeverityV3::Warning, + source: Some(warning.source()), + task: None, + } +} + +/// RFC 3339 UTC timestamp without a date-time dependency. Civil-date +/// math per Howard Hinnant's `civil_from_days` algorithm. +fn rfc3339_utc_now() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default(); + rfc3339_utc(secs) +} + +fn rfc3339_utc(secs_since_epoch: u64) -> String { + let days = i64::try_from(secs_since_epoch / 86_400).unwrap_or(i64::MAX); + let rem = secs_since_epoch % 86_400; + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z", + hour = rem / 3600, + minute = (rem % 3600) / 60, + second = rem % 60, + ) +} + +/// Days-since-epoch → (year, month, day) in the proleptic Gregorian +/// calendar. +const fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + (if month <= 2 { year + 1 } else { year }, month, day) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{DoctorReportV3, rfc3339_utc}; + use crate::resolver::ResolutionOverrides; + use crate::types::{PackageManager, ProjectContext, Task, TaskSource}; + + fn context(tasks: Vec) -> ProjectContext { + ProjectContext { + root: PathBuf::from("/tmp/test"), + package_managers: vec![PackageManager::Cargo], + task_runners: Vec::new(), + tasks, + node_version: None, + current_node: None, + is_monorepo: false, + warnings: Vec::new(), + } + } + + fn task(name: &str, source: TaskSource) -> Task { + Task { + name: name.to_string(), + source, + run_target: None, + description: None, + alias_of: None, + passthrough_to: None, + } + } + + #[test] + fn rfc3339_known_vectors() { + assert_eq!(rfc3339_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(rfc3339_utc(86_400), "1970-01-02T00:00:00Z"); + // 2000-02-29 — leap day in a century-leap year. + assert_eq!(rfc3339_utc(951_782_400), "2000-02-29T00:00:00Z"); + assert_eq!(rfc3339_utc(951_868_799), "2000-02-29T23:59:59Z"); + assert_eq!(rfc3339_utc(951_868_800), "2000-03-01T00:00:00Z"); + } + + #[test] + fn v3_report_carries_contract_constants() { + let ctx = context(vec![]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + assert_eq!(json["kind"], "runner.doctor"); + assert_eq!(json["schema_version"], 3); + assert!( + json["$schema"] + .as_str() + .is_some_and(|s| s.contains("doctor.v3")) + ); + assert_eq!(json["resolution"]["fqn_policy"], "exact-only"); + assert_eq!(json["project"]["workspace"], serde_json::Value::Null); + assert!( + json["invocation"]["started_at"] + .as_str() + .is_some_and(|t| { t.len() == 20 && t.ends_with('Z') && t.as_bytes()[10] == b'T' }) + ); + } + + #[test] + fn v3_report_lists_rust_ecosystem_with_high_confidence() { + let ctx = context(vec![]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let eco = &json["ecosystems"][0]; + assert_eq!(eco["name"], "rust"); + assert_eq!(eco["selected_package_manager"], "cargo"); + assert_eq!(eco["decision"]["confidence"], "high"); + } + + #[test] + fn v3_report_surfaces_duplicate_names_as_conflicts() { + let mut alias = task("t", TaskSource::CargoAliases); + alias.alias_of = Some("test".to_string()); + let ctx = context(vec![alias, task("t", TaskSource::Justfile)]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let conflict = &json["conflicts"][0]; + assert_eq!(conflict["kind"], "duplicate-task-name"); + assert_eq!(conflict["selector"], "t"); + // The justfile recipe wins: same tier, but recipes rank before + // aliases. + assert_eq!(conflict["selected"], "root:just:t"); + assert_eq!( + conflict["shadowed"], + serde_json::json!(["root:cargo-alias:t"]) + ); + } + + #[test] + fn v3_report_resolves_cargo_alias_tasks() { + let mut alias = task("t", TaskSource::CargoAliases); + alias.alias_of = Some("test".to_string()); + let ctx = context(vec![alias]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let task = &json["tasks"][0]; + assert_eq!(task["fqn"], "root:cargo-alias:t"); + assert_eq!(task["resolved"], "cargo test"); + assert_eq!(task["source_pointer"], "alias.t"); + assert_eq!(task["dependencies"], serde_json::json!([])); + } + + #[test] + fn v3_report_probes_detected_pms_as_tools() { + let ctx = context(vec![]); + let report = DoctorReportV3::build(&ctx, &ResolutionOverrides::default(), false); + let json = serde_json::to_value(&report).expect("report should serialize"); + + let tool = &json["tools"][0]; + assert_eq!(tool["name"], "cargo"); + assert_eq!(tool["kind"], "package-manager"); + assert_eq!(tool["id"], "tool:package-manager:cargo"); + let status = tool["probe"]["status"].as_str().expect("probe status"); + assert!(status == "found" || status == "missing"); + } +} diff --git a/src/schema/labels.rs b/src/schema/labels.rs index a20ab75..917c59f 100644 --- a/src/schema/labels.rs +++ b/src/schema/labels.rs @@ -22,6 +22,7 @@ use crate::types::TaskSource; pub(crate) const fn source_label_for(source: TaskSource, schema_version: u32) -> &'static str { match schema_version { 1 => super::v1::source_label(source), - _ => super::v2::source_label(source), + 2 => super::v2::source_label(source), + _ => super::v3::source_label(source), } } diff --git a/src/schema/mod.rs b/src/schema/mod.rs index 96f2478..a52f986 100644 --- a/src/schema/mod.rs +++ b/src/schema/mod.rs @@ -24,10 +24,12 @@ //! convention (filename-style → tool names) was a rename, so it moved //! from v1 to v2. +pub(crate) mod doctor_v3; pub(crate) mod labels; pub(crate) mod project; pub(crate) mod v1; pub(crate) mod v2; +pub(crate) mod v3; // Re-export so callers write `crate::schema::Project` rather than // `crate::schema::project::Project`. The inner module stays public @@ -35,8 +37,13 @@ pub(crate) mod v2; // builder methods directly without going through this shim. pub(crate) use project::Project; -/// Highest JSON schema version this binary can produce. Increments on -/// any breaking change to the serialized contract. +/// Highest JSON schema version `list` can produce (and the version the +/// flat [`project::Project`] shape serves). Increments on any breaking +/// change to that serialized contract. +/// +/// Surfaces version independently: `doctor` is at +/// [`DOCTOR_CURRENT_VERSION`] and `why` at [`WHY_CURRENT_VERSION`]; +/// `list` stays here until a v3 contract for it exists. /// /// **v2** — source labels standardized to tool names (`"just"`, /// `"bacon"`, `"make"`, `"turbo"`, `"deno"`, `"task"`, `"mise"`). @@ -49,9 +56,28 @@ pub(crate) use project::Project; /// `--schema-version=1`. pub(crate) const CURRENT_VERSION: u32 = 2; -/// Validate that `requested` is a schema version this binary can produce. -/// Returns the version unchanged on success so callers can chain it -/// directly into the builder. +/// Highest JSON schema version `doctor` can produce. +/// +/// **v3** — structured diagnostic inventory ([`doctor_v3`]): +/// `invocation`/`environment`/`runner` provenance, per-ecosystem +/// decisions with confidence, first-class `sources`, `fqn`-keyed tasks, +/// PATH-probed `tools`, duplicate-name `conflicts`, flattened +/// `diagnostics`, and a self-describing `resolution` policy block. +pub(crate) const DOCTOR_CURRENT_VERSION: u32 = 3; + +/// Highest JSON schema version `why` can produce. +/// +/// **v3** — structured report: candidates become `{task, match}` pairs +/// carrying identity (`fqn`, `provider`, `kind`, `source`, +/// `source_pointer`), resolution data (`definition`, `resolved`, `cwd`, +/// `aliases`, `dependencies`), and the match/decision breakdown that +/// mirrors the run-time selection key. Cargo alias tasks are labeled +/// `"cargo-alias"` (see [`v3`]). +pub(crate) const WHY_CURRENT_VERSION: u32 = 3; + +/// Validate that `requested` is a schema version `doctor`/`list` can +/// produce. Returns the version unchanged on success so callers can +/// chain it directly into the builder. /// /// # Errors /// @@ -67,9 +93,47 @@ pub(crate) fn validate_schema_version(requested: u32) -> anyhow::Result { Ok(requested) } +/// Validate that `requested` is a schema version `doctor` can produce. +/// +/// # Errors +/// +/// Returns `Err` when `requested == 0` or `requested > +/// DOCTOR_CURRENT_VERSION`, advertising the doctor-specific range. +pub(crate) fn validate_doctor_schema_version(requested: u32) -> anyhow::Result { + if requested == 0 || requested > DOCTOR_CURRENT_VERSION { + anyhow::bail!( + "unsupported --schema-version {requested}; `runner doctor` speaks 1..={DOCTOR_CURRENT_VERSION}", + ); + } + Ok(requested) +} + +/// Canonical public URL of a committed output schema. +pub(crate) fn schema_url(command: &str, version: u32) -> String { + format!("https://kjanat.github.io/schemas/{command}.v{version}.schema.json") +} + +/// Validate that `requested` is a schema version `why` can produce. +/// +/// # Errors +/// +/// Returns `Err` when `requested == 0` or `requested > +/// WHY_CURRENT_VERSION`, advertising the why-specific supported range. +pub(crate) fn validate_why_schema_version(requested: u32) -> anyhow::Result { + if requested == 0 || requested > WHY_CURRENT_VERSION { + anyhow::bail!( + "unsupported --schema-version {requested}; `runner why` speaks 1..={WHY_CURRENT_VERSION}", + ); + } + Ok(requested) +} + #[cfg(test)] mod tests { - use super::{CURRENT_VERSION, labels::source_label_for, validate_schema_version}; + use super::{ + CURRENT_VERSION, DOCTOR_CURRENT_VERSION, WHY_CURRENT_VERSION, labels::source_label_for, + validate_doctor_schema_version, validate_schema_version, validate_why_schema_version, + }; use crate::types::TaskSource; #[test] @@ -104,9 +168,10 @@ mod tests { #[test] fn current_version_matches_v2_labels() { - // Regression guard: `CURRENT_VERSION` and the v2 module must - // stay in lock-step. If a future v3 lands, this test moves to - // assert against `v3::source_label` and `CURRENT_VERSION = 3`. + // Regression guard: `CURRENT_VERSION` (doctor/list) and the v2 + // module must stay in lock-step until their v3 contracts are + // reviewed and implemented; `why` versions independently via + // `WHY_CURRENT_VERSION`. assert_eq!(CURRENT_VERSION, 2); assert_eq!( source_label_for(TaskSource::Justfile, CURRENT_VERSION), @@ -114,6 +179,26 @@ mod tests { ); } + #[test] + fn why_version_matches_v3_labels() { + // v3's single label divergence: cargo aliases name the + // mechanism, freeing `provider` to carry `"cargo"`. + assert_eq!(WHY_CURRENT_VERSION, 3); + assert_eq!( + source_label_for(TaskSource::CargoAliases, WHY_CURRENT_VERSION), + "cargo-alias" + ); + // Everything else inherits v2 unchanged. + assert_eq!( + source_label_for(TaskSource::Justfile, WHY_CURRENT_VERSION), + "just" + ); + assert_eq!( + source_label_for(TaskSource::PackageJson, WHY_CURRENT_VERSION), + "package.json" + ); + } + #[test] fn validate_schema_version_accepts_supported_range() { assert_eq!(validate_schema_version(1).unwrap(), 1); @@ -132,5 +217,40 @@ mod tests { msg.contains("1..=2"), "error should advertise the supported range: {msg}", ); + + // doctor/list do not speak v3 yet — only `why` does. + let err = validate_schema_version(3).expect_err("doctor/list must reject v3"); + assert!(format!("{err}").contains("1..=2")); + } + + #[test] + fn validate_doctor_schema_version_spans_one_through_three() { + assert_eq!(DOCTOR_CURRENT_VERSION, 3); + assert_eq!(validate_doctor_schema_version(1).unwrap(), 1); + assert_eq!(validate_doctor_schema_version(2).unwrap(), 2); + assert_eq!(validate_doctor_schema_version(3).unwrap(), 3); + + let err = validate_doctor_schema_version(0).expect_err("v0 must error"); + assert!(format!("{err}").contains("unsupported")); + let err = validate_doctor_schema_version(4).expect_err("future versions must error"); + assert!( + format!("{err}").contains("1..=3"), + "error should advertise the doctor range", + ); + } + + #[test] + fn validate_why_schema_version_spans_one_through_three() { + assert_eq!(validate_why_schema_version(1).unwrap(), 1); + assert_eq!(validate_why_schema_version(2).unwrap(), 2); + assert_eq!(validate_why_schema_version(3).unwrap(), 3); + + let err = validate_why_schema_version(0).expect_err("v0 must error"); + assert!(format!("{err}").contains("unsupported")); + let err = validate_why_schema_version(4).expect_err("future versions must error"); + assert!( + format!("{err}").contains("1..=3"), + "error should advertise the why range", + ); } } diff --git a/src/schema/project.rs b/src/schema/project.rs index bcf6d4f..a844d41 100644 --- a/src/schema/project.rs +++ b/src/schema/project.rs @@ -523,13 +523,14 @@ const PATH_PROBE_PMS: [PackageManager; 4] = [ ]; /// Probe results for the signals section: every PATH hit, plus Volta -/// shim classification when requested. -struct ProbeSignals { - path_probe: BTreeMap<&'static str, Option>, - volta_shims: BTreeMap<&'static str, VoltaShimInfo>, +/// shim classification when requested. Shared with the v3 doctor +/// builder ([`super::doctor_v3`]), hence `pub(super)`. +pub(super) struct ProbeSignals { + pub(super) path_probe: BTreeMap<&'static str, Option>, + pub(super) volta_shims: BTreeMap<&'static str, VoltaShimInfo>, } -fn probe_signals(root: &std::path::Path, resolve_shims: bool) -> ProbeSignals { +pub(super) fn probe_signals(root: &std::path::Path, resolve_shims: bool) -> ProbeSignals { use std::env; use std::thread; diff --git a/src/schema/v3.rs b/src/schema/v3.rs new file mode 100644 index 0000000..3c6bdce --- /dev/null +++ b/src/schema/v3.rs @@ -0,0 +1,21 @@ +//! JSON schema **v3** — source labels for the structured reports. +//! +//! v3 applies to `runner why --json` ([`super::WHY_CURRENT_VERSION`]) +//! and `runner doctor --json` ([`super::DOCTOR_CURRENT_VERSION`]); +//! `list` remains capped at [`super::CURRENT_VERSION`] until a v3 +//! contract for it exists. The one label change: cargo alias tasks report +//! `"cargo-alias"` instead of `"cargo"`, so the `kind` field names the +//! *mechanism* (an `[alias]` table entry) rather than colliding with the +//! `provider` field, which already carries `"cargo"`. Every other label +//! defers to the frozen v2 table. + +use crate::types::TaskSource; + +/// v3 source label for a given [`TaskSource`]. Only +/// [`TaskSource::CargoAliases`] diverges from v2; see module docs. +pub(crate) const fn source_label(source: TaskSource) -> &'static str { + match source { + TaskSource::CargoAliases => "cargo-alias", + _ => super::v2::source_label(source), + } +}