diff --git a/.dprint.jsonc b/.dprint.jsonc index a02223e..32f1582 100644 --- a/.dprint.jsonc +++ b/.dprint.jsonc @@ -1,6 +1,7 @@ { + "$schema": "https://dprint.dev/schemas/v0.json", "useTabs": true, - "json": {}, + "json": { "jsonTrailingCommaFiles": [".zed/settings.json"], "trailingCommas": "jsonc" }, "markdown": { "textWrap": "maintain", "emphasisKind": "asterisks" }, "malva": {}, "markup": { @@ -10,13 +11,14 @@ "printWidth": 160, }, "shfmt": { - "associations": ["**/bin/{install-branch,run,runner}", "**/*.{bash,zsh,sh}"], + "associations": ["**/bin/{install-branch,.runner-dev}", "**/*.{bash,zsh,sh}"], "useTabs": true, "binaryNextLine": true, "switchCaseIndent": true, "experimentalZsh": true, }, "yaml": { "printWidth": 160 }, + "jsonSchemaSort": { "associations": ["**/schema.json", "**/*.schema.json"] }, "exec": { "commands": [ { "command": "tombi format - --stdin-filename {{file_path}}", "exts": ["toml"] }, @@ -31,19 +33,20 @@ "**/target", "**/dist", "**/downloads", - "**/schemas/*.json", + "schemas/*.example.json", ], "plugins": [ - "https://plugins.dprint.dev/typescript-0.96.1.wasm", - "https://plugins.dprint.dev/json-0.21.3.wasm", - "https://plugins.dprint.dev/markdown-0.22.1.wasm", + "https://plugins.dprint.dev/dockerfile-0.4.0.wasm", + "https://plugins.dprint.dev/exec-0.6.2.json@df98f54ffd3092b8a841aedd6d098a2651f16d0a796a40535774f1a8b4b9d463", "https://plugins.dprint.dev/g-plane/malva-v0.16.0.wasm", "https://plugins.dprint.dev/g-plane/markup_fmt-v0.27.3.wasm", "https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm", - "https://plugins.dprint.dev/exec-0.6.2.json@df98f54ffd3092b8a841aedd6d098a2651f16d0a796a40535774f1a8b4b9d463", + "https://plugins.dprint.dev/json-0.21.3.wasm", + "https://plugins.dprint.dev/kjanat/json-schema-sort-0.1.0.wasm", + "https://plugins.dprint.dev/kjanat/shfmt-1.0.0.wasm", "https://plugins.dprint.dev/kjanat/sortpackagejson-0.2.1.wasm", "https://plugins.dprint.dev/kjanat/svg-v0.4.1.wasm", - "https://plugins.dprint.dev/kjanat/shfmt-1.0.0.wasm", - "https://plugins.dprint.dev/dockerfile-0.4.0.wasm", + "https://plugins.dprint.dev/markdown-0.22.1.wasm", + "https://plugins.dprint.dev/typescript-0.96.1.wasm", ], } diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..dd99152 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,14 @@ +--- +name: Upload schemas to GitHub Pages +on: { push: { branches: [master], paths: [schemas/**] } } +permissions: { contents: read, id-token: write, pages: write } +jobs: + upload: + runs-on: ubuntu-slim + environment: { name: github-pages, url: "${{ steps.deployment.outputs.page_url }}" } + steps: [ + { uses: actions/checkout@v6, with: { sparse-checkout: schemas/*.schema.json } }, + { uses: actions/configure-pages@v5, with: { enablement: true }, id: conf }, + { uses: actions/upload-pages-artifact@v4, with: { path: . } }, + { uses: actions/deploy-pages@v4, id: deployment }, + ] diff --git a/.zed/settings.json b/.zed/settings.json index a89c499..4cbf194 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,7 +1,3 @@ -// Folder-specific settings -// -// For a full list of overridable settings, and general information on folder-specific settings, -// see the documentation: https://zed.dev/docs/configuring-zed#settings-files { "formatter": [ { "language_server": { "name": "dprint" } }, @@ -10,7 +6,21 @@ "prettier": { "allowed": false }, "lsp": { "json-language-server": { - "settings": { "json": { "schemas": [{ "fileMatch": ["targets.json"], "url": "./npm/targets.schema.json" }] } } + "settings": { + "json": { + "schemas": [ + { "fileMatch": ["targets.json"], "url": "./npm/targets.schema.json" }, + { "fileMatch": ["schemas/*.schema.json", "*.schema.json"], "url": "https://json-schema.org/draft/2020-12/schema" }, + { "fileMatch": ["schemas/doctor.v1.example.json", "doctor.v1.example.json"], "url": "./schemas/doctor.v1.schema.json" }, + { "fileMatch": ["schemas/doctor.v2.example.json", "doctor.v2.example.json"], "url": "./schemas/doctor.v2.schema.json" }, + { "fileMatch": ["schemas/list.v1.example.json", "list.v1.example.json"], "url": "./schemas/list.v1.schema.json" }, + { "fileMatch": ["schemas/list.v2.example.json", "list.v2.example.json"], "url": "./schemas/list.v2.schema.json" }, + { "fileMatch": ["schemas/why.v1.example.json", "why.v1.example.json"], "url": "./schemas/why.v1.schema.json" }, + { "fileMatch": ["schemas/why.v2.example.json", "why.v2.example.json"], "url": "./schemas/why.v2.schema.json" }, + { "fileMatch": ["schemas/doctor.v3-draft.example.json", "doctor.v3-draft.example.json"], "url": "./schemas/doctor.v3-draft.schema.json" } + ] + } + } }, "rust-analyzer": { "initialization_options": { @@ -20,6 +30,6 @@ } } }, - "file_types": { "SVG": ["*.ico"] }, + "file_types": { "SVG": ["*.ico"], "Shell Script": [".runner-dev", "justfile"] }, "languages": { "TOML": { "language_servers": ["tombi", "..."] } } } diff --git a/Cargo.lock b/Cargo.lock index ba4e4bb..210205d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -200,13 +206,19 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "hashlink" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -215,6 +227,16 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "is_executable" version = "1.0.5" @@ -236,6 +258,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "json-schema-sort" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0064742d7d654ade52f6a62cf9fed7a9bfe2434b96319d31ebbb60d54267f35" +dependencies = [ + "serde_json", +] + [[package]] name = "json5" version = "1.3.1" @@ -324,6 +355,7 @@ dependencies = [ "clap_complete", "clap_mangen", "colored", + "json-schema-sort", "json5", "schemars", "semver", @@ -427,6 +459,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", diff --git a/Cargo.toml b/Cargo.toml index c558d13..dfdd284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,11 @@ features = ["unstable-dynamic"] version = "0.3" optional = true +[dependencies.json-schema-sort] +version = "0.1" +default-features = false +optional = true + [dependencies.schemars] version = "1.2" optional = true @@ -95,7 +100,7 @@ features = ["parse", "serde"] default = ["run"] man = ["dep:clap_mangen"] run = [] -schema = ["dep:schemars"] +schema = ["dep:json-schema-sort", "dep:schemars"] [lints.clippy] all = { level = "deny", priority = -1 } diff --git a/bin/.runner-dev b/bin/.runner-dev new file mode 100755 index 0000000..513a306 --- /dev/null +++ b/bin/.runner-dev @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +invoked_name="$(basename -- "$0")" + +case "${invoked_name}" in + run | runner) ;; + *) + printf 'error: unsupported launcher name: %s\n' "${invoked_name}" >&2 + exit 1 + ;; +esac + +source_path="${BASH_SOURCE[0]}" + +while [[ -L "${source_path}" ]]; do + source_dir="$( + unset CDPATH + cd -- "$(dirname -- "${source_path}")" + pwd -P + )" + + link_target="$(readlink -- "${source_path}")" + + case "${link_target}" in + /*) source_path="${link_target}" ;; + *) source_path="${source_dir}/${link_target}" ;; + esac +done + +script_dir="$( + unset CDPATH + cd -- "$(dirname -- "${source_path}")" + pwd -P +)" + +manifest="${script_dir}/../Cargo.toml" +target_dir="${script_dir}/../target/runner-dev" + +[[ -f "${manifest}" ]] || { + printf 'error: %s not found\n' "${manifest}" >&2 + exit 1 +} + +exec cargo run --locked --quiet \ + --bin "${invoked_name}" \ + --features=run,man,schema \ + --manifest-path="${manifest}" \ + --target-dir="${target_dir}" \ + -- "$@" diff --git a/bin/run b/bin/run deleted file mode 100755 index bc19152..0000000 --- a/bin/run +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -SOURCE_PATH="$0" -while [ -L "${SOURCE_PATH}" ]; do - # shellcheck disable=SC1007 - SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "${SOURCE_PATH}")" && pwd)" - LINK_TARGET="$(readlink "${SOURCE_PATH}")" - case "${LINK_TARGET}" in - /*) SOURCE_PATH="${LINK_TARGET}" ;; - *) SOURCE_PATH="${SOURCE_DIR}/${LINK_TARGET}" ;; - esac -done -# shellcheck disable=SC1007 -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${SOURCE_PATH}")" && pwd)" -MANIFEST="${SCRIPT_DIR}/../Cargo.toml" -[ -f "${MANIFEST}" ] || { - printf 'error: %s not found\n' "${MANIFEST}" >&2 - exit 1 -} - -exec env CARGO_TERM_QUIET=true \ - cargo run --release --bin run \ - --manifest-path="${MANIFEST}" \ - -- "$@" diff --git a/bin/run b/bin/run new file mode 120000 index 0000000..bac4f91 --- /dev/null +++ b/bin/run @@ -0,0 +1 @@ +.runner-dev \ No newline at end of file diff --git a/bin/runner b/bin/runner deleted file mode 100755 index f706c49..0000000 --- a/bin/runner +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env bash -SOURCE_PATH="$0" -while [[ -L "${SOURCE_PATH}" ]]; do - # shellcheck disable=SC1007 - SOURCE_DIR="$(CDPATH= cd -- "$(dirname -- "${SOURCE_PATH}")" && pwd)" - LINK_TARGET="$(readlink "${SOURCE_PATH}")" - case "${LINK_TARGET}" in - /*) SOURCE_PATH="${LINK_TARGET}" ;; - *) SOURCE_PATH="${SOURCE_DIR}/${LINK_TARGET}" ;; - esac -done -# shellcheck disable=SC1007 -SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${SOURCE_PATH}")" && pwd)" -MANIFEST="${SCRIPT_DIR}/../Cargo.toml" -[[ -f "${MANIFEST}" ]] || { - printf 'error: %s not found\n' "${MANIFEST}" >&2 - exit 1 -} - -exec env CARGO_TERM_QUIET=true \ - cargo run --release --locked \ - --manifest-path="${MANIFEST}" \ - -- "$@" diff --git a/bin/runner b/bin/runner new file mode 120000 index 0000000..bac4f91 --- /dev/null +++ b/bin/runner @@ -0,0 +1 @@ +.runner-dev \ No newline at end of file diff --git a/justfile b/justfile index b1320c3..f6e86d9 100644 --- a/justfile +++ b/justfile @@ -8,7 +8,7 @@ npm-pkg-scope := `cargo metadata --format-version 1 --no-deps | jq -r --arg id " build-pkgscript := "npm" / "scripts" / "build-packages.ts" downloads-dir := "npm" / "downloads" -schema := "schemas" / "runner.toml.schema.json" +schema-dir := "schemas" [arg('bin', pattern='run|runner')] [arg('profile', pattern='dev|release|')] @@ -27,12 +27,12 @@ runner *args: ls: @just --list -# Regenerate the committed JSON Schema for `runner.toml`. +# Regenerate the committed JSON Schemas. # Drift guard: just gen-schema && git diff --exit-code schemas/ [group('schema')] gen-schema: - @echo "→ regenerating {{ BLUE }}{{ schema }}{{ NORMAL }}" - @cargo schema --output {{ schema }} + @echo "→ regenerating {{ BLUE }}{{ schema-dir }}{{ NORMAL }}" + @cargo schema --all --output {{ schema-dir }} [group('npm')] build-packages only="" skip="false" version=cargo-version: diff --git a/npm/targets.schema.json b/npm/targets.schema.json index 0441761..631dbc5 100644 --- a/npm/targets.schema.json +++ b/npm/targets.schema.json @@ -1,155 +1,253 @@ { - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://github.com/kjanat/runner/blob/master/npm/targets.schema.json", - "title": "runner npm targets matrix", - "description": "Build matrix for the runner-run façade and its per-platform optionalDependencies. Consumed by npm/scripts/build-packages.ts and the release workflow.", - "type": "object", - "additionalProperties": false, - "required": ["facade", "scope", "binaries", "targets"], - "properties": { - "$schema": { - "type": "string", - "description": "Pointer to this schema for editor tooling.", - "examples": ["https://github.com/kjanat/runner/blob/master/npm/targets.schema.json", "./targets.schema.json"] - }, - "facade": { - "type": "string", - "description": "npm package name of the façade (the package users install). \nResolves to the correct platform sub-package via \"optionalDependencies\".", - "markdownDescription": "# Facade package name \nnpm `package` name of the façade (the package users install). \nResolves to the correct platform sub-package via `optionalDependencies`.", - "minLength": 1, - "default": "runner-run", - "examples": ["runner-run"] - }, - "scope": { - "type": "string", - "description": "npm scope under which platform sub-packages are published. \nEach sub-package is published as \"/\".", - "markdownDescription": "# npm scope \nScope under which platform sub-packages are published. \nEach sub-package is published as `/`.", - "pattern": "^@[a-z0-9][a-z0-9-]*$", - "default": "@runner-run", - "examples": ["@runner-run"] - }, - "binaries": { - "type": "array", - "description": "Binary names extracted from each release tarball and shipped in every platform sub-package's \"bin/\" directory. \nOn Windows, \".exe\" is appended automatically.", - "markdownDescription": "# Binary names \nBinary names extracted from each release tarball and shipped in every platform sub-package's `bin/` directory. \nOn Windows, `.exe` is appended automatically.", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^[a-zA-Z0-9_-]+$" - } - }, - "targets": { - "type": "array", - "description": "Per-platform build targets. \nOne entry per published sub-package.", - "markdownDescription": "# Build targets \nPer-platform build targets. One entry per published sub-package.", - "minItems": 1, - "items": { "$ref": "#/$defs/target" } - } - }, - "$defs": { - "target": { - "type": "object", - "description": "Build target for a specific platform/configuration. \nUsed to generate release tarballs and platform-specific sub-packages.", - "markdownDescription": "# Build target \nBuild target for a specific platform/configuration. \nUsed to generate release tarballs and platform-specific sub-packages.", - "additionalProperties": false, - "required": ["pkg", "rust", "os", "cpu", "runner", "build", "tier"], - "properties": { - "pkg": { - "type": "string", - "description": "Final package name is \"/\".", - "markdownDescription": "# Sub-package name suffix. \nFinal package name is `/`. \n- [NPM Scope](https://docs.npmjs.com/cli/v11/using-npm/scope)\n- [NPM Package name rules](https://docs.npmjs.com/cli/v11/using-npm/package-spec#package-name)\n- [NPM `package.json#name`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name)", - "pattern": "^[a-z0-9][a-z0-9-]*$" - }, - "rust": { - "type": "string", - "description": "Passed to \"cargo build --target\" / \"cross build --target\". \nAlso used to locate the release tarball: \"runner--.tar.gz\".", - "markdownDescription": "# Rust target triple \nPassed to `cargo build --target` / `cross build --target`. \nAlso used to locate the release tarball: `runner--.tar.gz`.", - "pattern": "^[a-z0-9_]+(-[a-z0-9_]+){2,3}$" - }, - "os": { - "type": "array", - "markdownTitle": "# Operating system", - "description": "npm \"os\" field — values from Node's \"process.platform\". \nnpm uses this to select the correct \"optionalDependency\" at install time.", - "markdownDescription": "# Operating system \n[npm `os` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#os) — values from Node's [`process.platform`](https://nodejs.org/api/process.html#processplatform). \nUsed to select the correct [`optionalDependency`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#optionaldependencies) at install time.", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "enum": ["aix", "darwin", "freebsd", "linux", "netbsd", "openbsd", "sunos", "win32"] - } - }, - "cpu": { - "type": "array", - "description": "npm \"cpu\" field — values from Node's \"process.arch\".", - "markdownDescription": "# CPU architecture \n[npm `cpu` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#cpu) — values from Node's [`process.arch`](https://nodejs.org/api/process.html#processarch).", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "enum": ["arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc64", "riscv64", "s390", "s390x", "x64"] - } - }, - "libc": { - "type": "array", - "description": "npm \"libc\" field — only meaningful on Linux. \nDistinguishes glibc and musl builds so the right sub-package is installed on Alpine etc.", - "markdownDescription": "# libc variant \n[npm `libc` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#libc) — only meaningful on Linux. \nDistinguishes glibc and musl builds so the right sub-package is installed on Alpine etc.", - "minItems": 1, - "uniqueItems": true, - "items": { "type": "string", "enum": ["glibc", "musl"] } - }, - "runner": { - "type": "string", - "description": "Hosted/self-hosted runner labels.", - "markdownDescription": "# GitHub Actions runner label \nHosted/self-hosted runner labels.", - "minLength": 1, - "examples": ["ubuntu-latest", "macos-latest", "macos-15-intel", "windows-latest", "windows-11-arm"] - }, - "build": { - "type": "string", - "description": "- \"cargo\" for native compilation on a matching runner; \n- \"cross\" for cross-compilation via the \"cross\" crate (typically Linux → { Linux, *BSD }); \n- \"cargo-cross-toolchain\" for stable cross-compilation via taiki-e/setup-cross-toolchain-action (Linux → tier-2 BSD/illumos with prebuilt std); \n- \"cargo-build-std\" for tier-3 cross-compilation via setup-cross-toolchain-action + nightly + -Z build-std (Linux → tier-3 BSD without prebuilt std); \n- \"vm\" for builds that run inside a target-OS VM (e.g. OpenBSD on a vmactions/openbsd-vm sidecar) and bypass the standard upload-assets matrix.", - "markdownDescription": "# Build tool \n- `cargo` — native compilation on a matching runner. \n- `cross` — cross-compilation via the `cross` crate (Linux → { Linux, *BSD } where cross has a maintained image). \n- `cargo-cross-toolchain` — cross-compilation via [`taiki-e/setup-cross-toolchain-action`](https://github.com/taiki-e/setup-cross-toolchain-action) on a Linux runner; uses host `cargo` with a real cross C toolchain. For tier-2 BSD/illumos targets where `std` is prebuilt. \n- `cargo-build-std` — tier-3 cross-compilation: `setup-cross-toolchain-action` + nightly Rust + `-Z build-std`. The release workflow handles the manual build/package/upload because `taiki-e/upload-rust-binary-action` cannot inject `-Z build-std`. \n- `vm` — built inside a target-OS VM (e.g. OpenBSD via [`vmactions/openbsd-vm`](https://github.com/vmactions/openbsd-vm)). Bypasses the matrix-driven upload-assets job; handled by a dedicated workflow job.", - "enum": ["cargo", "cross", "cargo-cross-toolchain", "cargo-build-std", "vm"] - }, - "tier": { - "type": "integer", - "description": "1 = first-class (release-blocking), \n2 = best-effort, \n3 = experimental (failures don't block release).", - "markdownDescription": "# Support tier \n1 = first-class (release-blocking), \n2 = best-effort, \n3 = experimental (failures don't block release).", - "enum": [1, 2, 3] - }, - "experimental": { - "type": "boolean", - "title": "Experimental flag", - "description": "Used by the release workflow as \"continue-on-error\". \nOnly valid on tier-3 targets.", - "default": false - } - }, - "dependentSchemas": { - "libc": { - "description": "libc is only meaningful on Linux. If declared, os must include linux.", - "properties": { "os": { "type": "array", "contains": { "const": "linux" } } }, - "required": ["os"] - } - }, - "allOf": [{ - "if": { - "properties": { "os": { "type": "array", "contains": { "const": "linux" } } }, - "required": ["os"] - }, - "then": { - "required": ["libc"], - "description": "Linux targets must declare libc to disambiguate glibc and musl builds at install time." - } - }, { - "if": { - "properties": { "experimental": { "const": true } }, - "required": ["experimental"] - }, - "then": { - "properties": { "tier": { "const": 3 } }, - "description": "Only tier-3 targets may be marked experimental." - } - }] - } - } + "$id": "https://github.com/kjanat/runner/blob/master/npm/targets.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "target": { + "description": "Build target for a specific platform/configuration. \nUsed to generate release tarballs and platform-specific sub-packages.", + "markdownDescription": "# Build target \nBuild target for a specific platform/configuration. \nUsed to generate release tarballs and platform-specific sub-packages.", + "type": "object", + "required": [ + "build", + "cpu", + "os", + "pkg", + "runner", + "rust", + "tier" + ], + "properties": { + "build": { + "description": "- \"cargo\" for native compilation on a matching runner; \n- \"cross\" for cross-compilation via the \"cross\" crate (typically Linux → { Linux, *BSD }); \n- \"cargo-cross-toolchain\" for stable cross-compilation via taiki-e/setup-cross-toolchain-action (Linux → tier-2 BSD/illumos with prebuilt std); \n- \"cargo-build-std\" for tier-3 cross-compilation via setup-cross-toolchain-action + nightly + -Z build-std (Linux → tier-3 BSD without prebuilt std); \n- \"vm\" for builds that run inside a target-OS VM (e.g. OpenBSD on a vmactions/openbsd-vm sidecar) and bypass the standard upload-assets matrix.", + "markdownDescription": "# Build tool \n- `cargo` — native compilation on a matching runner. \n- `cross` — cross-compilation via the `cross` crate (Linux → { Linux, *BSD } where cross has a maintained image). \n- `cargo-cross-toolchain` — cross-compilation via [`taiki-e/setup-cross-toolchain-action`](https://github.com/taiki-e/setup-cross-toolchain-action) on a Linux runner; uses host `cargo` with a real cross C toolchain. For tier-2 BSD/illumos targets where `std` is prebuilt. \n- `cargo-build-std` — tier-3 cross-compilation: `setup-cross-toolchain-action` + nightly Rust + `-Z build-std`. The release workflow handles the manual build/package/upload because `taiki-e/upload-rust-binary-action` cannot inject `-Z build-std`. \n- `vm` — built inside a target-OS VM (e.g. OpenBSD via [`vmactions/openbsd-vm`](https://github.com/vmactions/openbsd-vm)). Bypasses the matrix-driven upload-assets job; handled by a dedicated workflow job.", + "type": "string", + "enum": [ + "cargo", + "cross", + "cargo-cross-toolchain", + "cargo-build-std", + "vm" + ] + }, + "cpu": { + "description": "npm \"cpu\" field — values from Node's \"process.arch\".", + "markdownDescription": "# CPU architecture \n[npm `cpu` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#cpu) — values from Node's [`process.arch`](https://nodejs.org/api/process.html#processarch).", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "arm", + "arm64", + "ia32", + "loong64", + "mips", + "mipsel", + "ppc64", + "riscv64", + "s390", + "s390x", + "x64" + ] + } + }, + "experimental": { + "title": "Experimental flag", + "description": "Used by the release workflow as \"continue-on-error\". \nOnly valid on tier-3 targets.", + "default": false, + "type": "boolean" + }, + "libc": { + "description": "npm \"libc\" field — only meaningful on Linux. \nDistinguishes glibc and musl builds so the right sub-package is installed on Alpine etc.", + "markdownDescription": "# libc variant \n[npm `libc` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#libc) — only meaningful on Linux. \nDistinguishes glibc and musl builds so the right sub-package is installed on Alpine etc.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "glibc", + "musl" + ] + } + }, + "os": { + "description": "npm \"os\" field — values from Node's \"process.platform\". \nnpm uses this to select the correct \"optionalDependency\" at install time.", + "markdownDescription": "# Operating system \n[npm `os` field](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#os) — values from Node's [`process.platform`](https://nodejs.org/api/process.html#processplatform). \nUsed to select the correct [`optionalDependency`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#optionaldependencies) at install time.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "aix", + "darwin", + "freebsd", + "linux", + "netbsd", + "openbsd", + "sunos", + "win32" + ] + }, + "markdownTitle": "# Operating system" + }, + "pkg": { + "description": "Final package name is \"/\".", + "markdownDescription": "# Sub-package name suffix. \nFinal package name is `/`. \n- [NPM Scope](https://docs.npmjs.com/cli/v11/using-npm/scope)\n- [NPM Package name rules](https://docs.npmjs.com/cli/v11/using-npm/package-spec#package-name)\n- [NPM `package.json#name`](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#name)", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$" + }, + "runner": { + "description": "Hosted/self-hosted runner labels.", + "markdownDescription": "# GitHub Actions runner label \nHosted/self-hosted runner labels.", + "examples": [ + "ubuntu-latest", + "macos-latest", + "macos-15-intel", + "windows-latest", + "windows-11-arm" + ], + "type": "string", + "minLength": 1 + }, + "rust": { + "description": "Passed to \"cargo build --target\" / \"cross build --target\". \nAlso used to locate the release tarball: \"runner--.tar.gz\".", + "markdownDescription": "# Rust target triple \nPassed to `cargo build --target` / `cross build --target`. \nAlso used to locate the release tarball: `runner--.tar.gz`.", + "type": "string", + "pattern": "^[a-z0-9_]+(-[a-z0-9_]+){2,3}$" + }, + "tier": { + "description": "1 = first-class (release-blocking), \n2 = best-effort, \n3 = experimental (failures don't block release).", + "markdownDescription": "# Support tier \n1 = first-class (release-blocking), \n2 = best-effort, \n3 = experimental (failures don't block release).", + "type": "integer", + "enum": [ + 1, + 2, + 3 + ] + } + }, + "additionalProperties": false, + "dependentSchemas": { + "libc": { + "description": "libc is only meaningful on Linux. If declared, os must include linux.", + "required": [ + "os" + ], + "properties": { + "os": { + "type": "array", + "contains": { + "const": "linux" + } + } + } + } + }, + "allOf": [ + { + "if": { + "required": [ + "os" + ], + "properties": { + "os": { + "type": "array", + "contains": { + "const": "linux" + } + } + } + }, + "then": { + "description": "Linux targets must declare libc to disambiguate glibc and musl builds at install time.", + "required": [ + "libc" + ] + } + }, + { + "if": { + "required": [ + "experimental" + ], + "properties": { + "experimental": { + "const": true + } + } + }, + "then": { + "description": "Only tier-3 targets may be marked experimental.", + "properties": { + "tier": { + "const": 3 + } + } + } + } + ] + } + }, + "title": "runner npm targets matrix", + "description": "Build matrix for the runner-run façade and its per-platform optionalDependencies. Consumed by npm/scripts/build-packages.ts and the release workflow.", + "type": "object", + "required": [ + "binaries", + "facade", + "scope", + "targets" + ], + "properties": { + "$schema": { + "description": "Pointer to this schema for editor tooling.", + "examples": [ + "https://github.com/kjanat/runner/blob/master/npm/targets.schema.json", + "./targets.schema.json" + ], + "type": "string" + }, + "binaries": { + "description": "Binary names extracted from each release tarball and shipped in every platform sub-package's \"bin/\" directory. \nOn Windows, \".exe\" is appended automatically.", + "markdownDescription": "# Binary names \nBinary names extracted from each release tarball and shipped in every platform sub-package's `bin/` directory. \nOn Windows, `.exe` is appended automatically.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-zA-Z0-9_-]+$" + } + }, + "facade": { + "description": "npm package name of the façade (the package users install). \nResolves to the correct platform sub-package via \"optionalDependencies\".", + "markdownDescription": "# Facade package name \nnpm `package` name of the façade (the package users install). \nResolves to the correct platform sub-package via `optionalDependencies`.", + "default": "runner-run", + "examples": [ + "runner-run" + ], + "type": "string", + "minLength": 1 + }, + "scope": { + "description": "npm scope under which platform sub-packages are published. \nEach sub-package is published as \"/\".", + "markdownDescription": "# npm scope \nScope under which platform sub-packages are published. \nEach sub-package is published as `/`.", + "default": "@runner-run", + "examples": [ + "@runner-run" + ], + "type": "string", + "pattern": "^@[a-z0-9][a-z0-9-]*$" + }, + "targets": { + "description": "Per-platform build targets. \nOne entry per published sub-package.", + "markdownDescription": "# Build targets \nPer-platform build targets. One entry per published sub-package.", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/target" + } + } + }, + "additionalProperties": false } diff --git a/package.json b/package.json index 211fdfe..b084c03 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ ], "scripts": { "fmt": "bunx dprint fmt", + "fmt:update": "bunx dprint config update --yes", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 253b8f6..f4f1cab 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" +channel = "stable" components = ["rustfmt", "clippy", "rust-analyzer"] profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..ec25a1d --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = 2024 diff --git a/schemas/doctor.v1.example.json b/schemas/doctor.v1.example.json new file mode 100644 index 0000000..68e8a8a --- /dev/null +++ b/schemas/doctor.v1.example.json @@ -0,0 +1,234 @@ +{ + "schema_version": 1, + "root": "/home/kjanat/projects/runner", + "ecosystems": [ + "node", + "rust" + ], + "detected": { + "package_managers": [ + "bun", + "cargo" + ], + "task_runners": [ + "just" + ], + "node_version": null, + "current_node": "24.14.1", + "monorepo": true + }, + "overrides": { + "pm": null, + "pm_by_ecosystem": {}, + "runner": null, + "prefer_runners": [], + "fallback": "probe", + "on_mismatch": "warn", + "explain": false, + "no_warnings": false + }, + "signals": { + "node": { + "lockfile_pm": "bun", + "manifest_pm": { + "pm": "bun", + "source": "packageManager", + "version": "1.3.14", + "on_fail": "ignore" + }, + "path_probe": { + "bun": "/home/kjanat/.bun/bin/bun", + "npm": "/home/kjanat/.volta/bin/npm", + "pnpm": "/home/kjanat/.volta/bin/pnpm", + "yarn": "/home/kjanat/.volta/bin/yarn" + }, + "volta_shims": { + "npm": { + "resolved": "/home/kjanat/.volta/tools/image/npm/11.6.2/bin/npm" + }, + "pnpm": { + "resolved": "/home/kjanat/.volta/tools/image/packages/pnpm/bin/pnpm" + }, + "yarn": { + "resolved": "/home/kjanat/.volta/tools/image/yarn/1.22.22/bin/yarn" + } + } + } + }, + "decisions": { + "node_pm": { + "pm": "bun", + "via": "bun via package.json \"packageManager\"" + } + }, + "tasks": [ + { + "name": "fmt", + "source": "package.json" + }, + { + "name": "fmt:update", + "source": "package.json" + }, + { + "name": "typecheck", + "source": "package.json" + }, + { + "name": "build-packages", + "source": "justfile" + }, + { + "name": "default", + "source": "justfile" + }, + { + "name": "gen-schema", + "source": "justfile", + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" + }, + { + "name": "ls", + "source": "justfile" + }, + { + "name": "run", + "source": "justfile" + }, + { + "name": "runner", + "source": "justfile" + }, + { + "name": "test-release", + "source": "justfile", + "description": "Build release bin and verify the facade shims spawn the native binary." + }, + { + "name": "b", + "source": "cargo", + "alias_of": "build" + }, + { + "name": "bb", + "source": "cargo", + "alias_of": "build --bin run --bin runner" + }, + { + "name": "bbr", + "source": "cargo", + "alias_of": "build --bin run --bin runner --release" + }, + { + "name": "bin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run" + }, + { + "name": "bin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "c", + "source": "cargo", + "alias_of": "check" + }, + { + "name": "cl", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features" + }, + { + "name": "comp", + "source": "cargo", + "alias_of": "run --quiet --bin runner -- completions" + }, + { + "name": "d", + "source": "cargo", + "alias_of": "doc" + }, + { + "name": "f", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "format", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "i", + "source": "cargo", + "alias_of": "install --path ." + }, + { + "name": "l", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "lint", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "man", + "source": "cargo", + "alias_of": "run --quiet --features man -- man" + }, + { + "name": "meta", + "source": "cargo", + "alias_of": "metadata --format-version 1" + }, + { + "name": "r", + "source": "cargo", + "alias_of": "run" + }, + { + "name": "rbin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run --release" + }, + { + "name": "rbin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner --release" + }, + { + "name": "rm", + "source": "cargo", + "alias_of": "remove" + }, + { + "name": "rq", + "source": "cargo", + "alias_of": "run --quiet" + }, + { + "name": "rr", + "source": "cargo", + "alias_of": "run --release" + }, + { + "name": "runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "schema", + "source": "cargo", + "alias_of": "run --quiet --features schema -- schema" + }, + { + "name": "t", + "source": "cargo", + "alias_of": "test" + } + ], + "warnings": [] +} diff --git a/schemas/doctor.v1.schema.json b/schemas/doctor.v1.schema.json new file mode 100644 index 0000000..72d98f1 --- /dev/null +++ b/schemas/doctor.v1.schema.json @@ -0,0 +1,471 @@ +{ + "$id": "https://kjanat.github.io/schemas/doctor.v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Decisions": { + "description": "Resolver verdict surface. Mirrors the resolver's `Result` so\nconsumers can branch on the variant before reading the inner shape.", + "type": "object", + "required": [ + "node_pm" + ], + "properties": { + "node_pm": { + "$ref": "#/$defs/NodePmDecision", + "description": "Node script-dispatch PM decision, or an error message when the\nresolver bailed." + } + } + }, + "Detected": { + "description": "Detection results — what the file scan found, before any resolver\npolicy was applied.", + "type": "object", + "required": [ + "current_node", + "monorepo", + "node_version", + "package_managers", + "task_runners" + ], + "properties": { + "current_node": { + "description": "`node --version` output, when the binary is on PATH.", + "type": [ + "null", + "string" + ] + }, + "monorepo": { + "description": "Whether the project looks like a monorepo (workspace globs).", + "type": "boolean" + }, + "node_version": { + "description": "`.nvmrc` / `.node-version` / `engines.node` declaration.", + "anyOf": [ + { + "$ref": "#/$defs/NodeVersionInfo" + }, + { + "type": "null" + } + ] + }, + "package_managers": { + "description": "Detected package managers, in detection-priority order.", + "type": "array", + "items": { + "type": "string" + } + }, + "task_runners": { + "description": "Detected task runners.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ManifestPm": { + "description": "Manifest-level PM declaration plus the field it came from.", + "type": "object", + "required": [ + "on_fail", + "pm", + "source", + "version" + ], + "properties": { + "on_fail": { + "description": "Effective `onFail` policy (`\"ignore\"`, `\"warn\"`, `\"error\"`).", + "type": "string" + }, + "pm": { + "description": "Declared PM label.", + "type": "string" + }, + "source": { + "description": "Either `\"packageManager\"` or `\"devEngines.packageManager\"`.", + "type": "string" + }, + "version": { + "description": "Version constraint as written, if present.", + "type": [ + "null", + "string" + ] + } + } + }, + "NodePmDecision": { + "description": "Either a resolved Node PM or the diagnostic string for the failure\nthat prevented one. Untagged so consumers can probe via \"is the\n`pm` field present?\".", + "anyOf": [ + { + "description": "Successful resolution.", + "type": "object", + "required": [ + "pm", + "via" + ], + "properties": { + "pm": { + "description": "The chosen PM label.", + "type": "string" + }, + "via": { + "description": "Human-readable `via` line — the same string `--explain` prints.", + "type": "string" + } + } + }, + { + "description": "Resolver bailed; carries the rendered error message.", + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "description": "One-line error description from `ResolveError::Display`.", + "type": "string" + } + } + } + ] + }, + "NodeSignals": { + "description": "Node-ecosystem detection signals: lockfile, manifest, PATH probe.", + "type": "object", + "required": [ + "lockfile_pm", + "manifest_pm", + "path_probe" + ], + "properties": { + "lockfile_pm": { + "description": "PM inferred from the highest-priority lockfile, if any.", + "type": [ + "null", + "string" + ] + }, + "manifest_pm": { + "description": "Manifest declaration (legacy `packageManager` or `devEngines`).", + "anyOf": [ + { + "$ref": "#/$defs/ManifestPm" + }, + { + "type": "null" + } + ] + }, + "path_probe": { + "description": "`bun`/`pnpm`/`yarn`/`npm` -> absolute path on `$PATH` (or null).", + "type": "object", + "additionalProperties": { + "type": [ + "null", + "string" + ] + } + }, + "volta_shims": { + "description": "PATH-probe hits identified as Volta shims, keyed like\n[`Self::path_probe`]. Additive field (no schema bump): absent on\nhosts without Volta and on surfaces that skip shim resolution.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/VoltaShimInfo" + } + } + } + }, + "NodeVersionInfo": { + "description": "Node version declaration plus the file it came from.", + "type": "object", + "required": [ + "expected", + "source" + ], + "properties": { + "expected": { + "description": "Version string as written (e.g. `\"20.11.0\"`, `\">=18\"`).", + "type": "string" + }, + "source": { + "description": "Source file that declared the version (e.g. `\".nvmrc\"`).", + "type": "string" + } + } + }, + "OverridesView": { + "description": "Materialised override stack — the inputs that fed into resolver\ndecisions.", + "type": "object", + "required": [ + "explain", + "fallback", + "no_warnings", + "on_mismatch", + "pm", + "pm_by_ecosystem", + "prefer_runners", + "runner" + ], + "properties": { + "explain": { + "description": "Whether the explain trace is on.", + "type": "boolean" + }, + "fallback": { + "description": "Active `FallbackPolicy` label.", + "type": "string" + }, + "no_warnings": { + "description": "Whether warnings are suppressed.", + "type": "boolean" + }, + "on_mismatch": { + "description": "Active `MismatchPolicy` label.", + "type": "string" + }, + "pm": { + "description": "Cross-ecosystem PM override from `--pm` / `RUNNER_PM`.", + "anyOf": [ + { + "$ref": "#/$defs/PmOverrideInfo" + }, + { + "type": "null" + } + ] + }, + "pm_by_ecosystem": { + "description": "Per-ecosystem PM overrides from `runner.toml [pm].`.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/PmOverrideInfo" + } + }, + "prefer_runners": { + "description": "Ranked preference list from `[task_runner].prefer`.", + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "description": "`--runner` / `RUNNER_RUNNER` override.", + "anyOf": [ + { + "$ref": "#/$defs/RunnerOverrideInfo" + }, + { + "type": "null" + } + ] + } + } + }, + "PmOverrideInfo": { + "description": "PM override + provenance.", + "type": "object", + "required": [ + "origin", + "pm" + ], + "properties": { + "origin": { + "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", + "type": "string" + }, + "pm": { + "description": "The chosen PM label.", + "type": "string" + } + } + }, + "RunnerOverrideInfo": { + "description": "Task-runner override + provenance.", + "type": "object", + "required": [ + "origin", + "runner" + ], + "properties": { + "origin": { + "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", + "type": "string" + }, + "runner": { + "description": "The chosen runner label.", + "type": "string" + } + } + }, + "Signals": { + "description": "Per-ecosystem signals — what the resolver had to work with.", + "type": "object", + "required": [ + "node" + ], + "properties": { + "node": { + "$ref": "#/$defs/NodeSignals", + "description": "Node-ecosystem signals. The schema is intentionally\nnode-flat today; other ecosystems get peer fields as their\nresolver paths land." + } + } + }, + "TaskInfo": { + "description": "Task entry projected into the JSON shape.", + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "alias_of": { + "description": "When the task is an alias, the target it resolves to.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "Human-readable description, if any.", + "type": [ + "null", + "string" + ] + }, + "name": { + "description": "Task name as it appears in the config.", + "type": "string" + }, + "passthrough_to": { + "description": "When the task's body is a thin wrapper for another runner.", + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "Makefile", + "justfile", + "Taskfile", + "turbo.json", + "deno.json", + "cargo", + "go", + "bacon.toml", + "mise.toml", + "pyproject.toml" + ] + }, + "VoltaShimInfo": { + "description": "What `volta which` said about one shimmed tool.", + "type": "object", + "required": [ + "resolved" + ], + "properties": { + "resolved": { + "description": "Real provisioned binary behind the shim; `null` when Volta has\nno version of the tool (\"not provisioned\"). Shims Volta could\nnot classify at all are omitted from the map instead of guessed.", + "type": [ + "null", + "string" + ] + } + } + }, + "WarningInfo": { + "description": "Warning projected into the JSON shape. The `source`/`detail` split\nis kept stable from the pre-A4 flat-struct days so existing\nconsumers (the `doctor` test suite, ad-hoc `jq` queries) keep\nworking.", + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "description": "Human-readable detail.", + "type": "string" + }, + "source": { + "description": "Subsystem the warning came from (e.g. `\"package.json\"`).", + "type": "string" + } + } + } + }, + "title": "runner doctor --json --schema-version 1", + "description": "JSON schema for the legacy v1 `runner doctor --json` document. v1 uses filename-style task source labels.", + "type": "object", + "required": [ + "decisions", + "detected", + "ecosystems", + "overrides", + "root", + "schema_version", + "signals", + "warnings" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "decisions": { + "$ref": "#/$defs/Decisions", + "description": "Resolver verdict (or first-class error if the chain bailed)." + }, + "detected": { + "$ref": "#/$defs/Detected", + "description": "Raw, type-deduplicated detection results: PMs, runners, Node\nversion, monorepo flag. Stable across resolver behavior tweaks." + }, + "ecosystems": { + "description": "Detected ecosystems, in the order their package managers were\nfound by [`crate::detect`].", + "type": "array", + "items": { + "type": "string" + } + }, + "overrides": { + "$ref": "#/$defs/OverridesView", + "description": "Effective override stack — CLI, env, and config bundled." + }, + "root": { + "description": "Absolute path of the project root the report describes.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 1, + "minimum": 0, + "format": "uint32" + }, + "signals": { + "$ref": "#/$defs/Signals", + "description": "Per-ecosystem detection signals: lockfile pick, manifest\ndeclaration, PATH probe results." + }, + "tasks": { + "description": "Full task list. Subcommands that don't care omit this via\nprojection.", + "type": "array", + "items": { + "$ref": "#/$defs/TaskInfo" + } + }, + "warnings": { + "description": "Diagnostic warnings from both detection (`ctx.warnings`) and\nthe resolver (`ResolvedPm.warnings`), flattened.", + "type": "array", + "items": { + "$ref": "#/$defs/WarningInfo" + } + } + } +} diff --git a/schemas/doctor.v2.example.json b/schemas/doctor.v2.example.json new file mode 100644 index 0000000..0c283e8 --- /dev/null +++ b/schemas/doctor.v2.example.json @@ -0,0 +1,234 @@ +{ + "schema_version": 2, + "root": "/home/kjanat/projects/runner", + "ecosystems": [ + "node", + "rust" + ], + "detected": { + "package_managers": [ + "bun", + "cargo" + ], + "task_runners": [ + "just" + ], + "node_version": null, + "current_node": "24.14.1", + "monorepo": true + }, + "overrides": { + "pm": null, + "pm_by_ecosystem": {}, + "runner": null, + "prefer_runners": [], + "fallback": "probe", + "on_mismatch": "warn", + "explain": false, + "no_warnings": false + }, + "signals": { + "node": { + "lockfile_pm": "bun", + "manifest_pm": { + "pm": "bun", + "source": "packageManager", + "version": "1.3.14", + "on_fail": "ignore" + }, + "path_probe": { + "bun": "/home/kjanat/.bun/bin/bun", + "npm": "/home/kjanat/.volta/bin/npm", + "pnpm": "/home/kjanat/.volta/bin/pnpm", + "yarn": "/home/kjanat/.volta/bin/yarn" + }, + "volta_shims": { + "npm": { + "resolved": "/home/kjanat/.volta/tools/image/npm/11.6.2/bin/npm" + }, + "pnpm": { + "resolved": "/home/kjanat/.volta/tools/image/packages/pnpm/bin/pnpm" + }, + "yarn": { + "resolved": "/home/kjanat/.volta/tools/image/yarn/1.22.22/bin/yarn" + } + } + } + }, + "decisions": { + "node_pm": { + "pm": "bun", + "via": "bun via package.json \"packageManager\"" + } + }, + "tasks": [ + { + "name": "fmt", + "source": "package.json" + }, + { + "name": "fmt:update", + "source": "package.json" + }, + { + "name": "typecheck", + "source": "package.json" + }, + { + "name": "build-packages", + "source": "just" + }, + { + "name": "default", + "source": "just" + }, + { + "name": "gen-schema", + "source": "just", + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" + }, + { + "name": "ls", + "source": "just" + }, + { + "name": "run", + "source": "just" + }, + { + "name": "runner", + "source": "just" + }, + { + "name": "test-release", + "source": "just", + "description": "Build release bin and verify the facade shims spawn the native binary." + }, + { + "name": "b", + "source": "cargo", + "alias_of": "build" + }, + { + "name": "bb", + "source": "cargo", + "alias_of": "build --bin run --bin runner" + }, + { + "name": "bbr", + "source": "cargo", + "alias_of": "build --bin run --bin runner --release" + }, + { + "name": "bin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run" + }, + { + "name": "bin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "c", + "source": "cargo", + "alias_of": "check" + }, + { + "name": "cl", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features" + }, + { + "name": "comp", + "source": "cargo", + "alias_of": "run --quiet --bin runner -- completions" + }, + { + "name": "d", + "source": "cargo", + "alias_of": "doc" + }, + { + "name": "f", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "format", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "i", + "source": "cargo", + "alias_of": "install --path ." + }, + { + "name": "l", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "lint", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "man", + "source": "cargo", + "alias_of": "run --quiet --features man -- man" + }, + { + "name": "meta", + "source": "cargo", + "alias_of": "metadata --format-version 1" + }, + { + "name": "r", + "source": "cargo", + "alias_of": "run" + }, + { + "name": "rbin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run --release" + }, + { + "name": "rbin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner --release" + }, + { + "name": "rm", + "source": "cargo", + "alias_of": "remove" + }, + { + "name": "rq", + "source": "cargo", + "alias_of": "run --quiet" + }, + { + "name": "rr", + "source": "cargo", + "alias_of": "run --release" + }, + { + "name": "runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "schema", + "source": "cargo", + "alias_of": "run --quiet --features schema -- schema" + }, + { + "name": "t", + "source": "cargo", + "alias_of": "test" + } + ], + "warnings": [] +} diff --git a/schemas/doctor.v2.schema.json b/schemas/doctor.v2.schema.json new file mode 100644 index 0000000..e9dc8f1 --- /dev/null +++ b/schemas/doctor.v2.schema.json @@ -0,0 +1,471 @@ +{ + "$id": "https://kjanat.github.io/schemas/doctor.v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "Decisions": { + "description": "Resolver verdict surface. Mirrors the resolver's `Result` so\nconsumers can branch on the variant before reading the inner shape.", + "type": "object", + "required": [ + "node_pm" + ], + "properties": { + "node_pm": { + "$ref": "#/$defs/NodePmDecision", + "description": "Node script-dispatch PM decision, or an error message when the\nresolver bailed." + } + } + }, + "Detected": { + "description": "Detection results — what the file scan found, before any resolver\npolicy was applied.", + "type": "object", + "required": [ + "current_node", + "monorepo", + "node_version", + "package_managers", + "task_runners" + ], + "properties": { + "current_node": { + "description": "`node --version` output, when the binary is on PATH.", + "type": [ + "null", + "string" + ] + }, + "monorepo": { + "description": "Whether the project looks like a monorepo (workspace globs).", + "type": "boolean" + }, + "node_version": { + "description": "`.nvmrc` / `.node-version` / `engines.node` declaration.", + "anyOf": [ + { + "$ref": "#/$defs/NodeVersionInfo" + }, + { + "type": "null" + } + ] + }, + "package_managers": { + "description": "Detected package managers, in detection-priority order.", + "type": "array", + "items": { + "type": "string" + } + }, + "task_runners": { + "description": "Detected task runners.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "ManifestPm": { + "description": "Manifest-level PM declaration plus the field it came from.", + "type": "object", + "required": [ + "on_fail", + "pm", + "source", + "version" + ], + "properties": { + "on_fail": { + "description": "Effective `onFail` policy (`\"ignore\"`, `\"warn\"`, `\"error\"`).", + "type": "string" + }, + "pm": { + "description": "Declared PM label.", + "type": "string" + }, + "source": { + "description": "Either `\"packageManager\"` or `\"devEngines.packageManager\"`.", + "type": "string" + }, + "version": { + "description": "Version constraint as written, if present.", + "type": [ + "null", + "string" + ] + } + } + }, + "NodePmDecision": { + "description": "Either a resolved Node PM or the diagnostic string for the failure\nthat prevented one. Untagged so consumers can probe via \"is the\n`pm` field present?\".", + "anyOf": [ + { + "description": "Successful resolution.", + "type": "object", + "required": [ + "pm", + "via" + ], + "properties": { + "pm": { + "description": "The chosen PM label.", + "type": "string" + }, + "via": { + "description": "Human-readable `via` line — the same string `--explain` prints.", + "type": "string" + } + } + }, + { + "description": "Resolver bailed; carries the rendered error message.", + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "description": "One-line error description from `ResolveError::Display`.", + "type": "string" + } + } + } + ] + }, + "NodeSignals": { + "description": "Node-ecosystem detection signals: lockfile, manifest, PATH probe.", + "type": "object", + "required": [ + "lockfile_pm", + "manifest_pm", + "path_probe" + ], + "properties": { + "lockfile_pm": { + "description": "PM inferred from the highest-priority lockfile, if any.", + "type": [ + "null", + "string" + ] + }, + "manifest_pm": { + "description": "Manifest declaration (legacy `packageManager` or `devEngines`).", + "anyOf": [ + { + "$ref": "#/$defs/ManifestPm" + }, + { + "type": "null" + } + ] + }, + "path_probe": { + "description": "`bun`/`pnpm`/`yarn`/`npm` -> absolute path on `$PATH` (or null).", + "type": "object", + "additionalProperties": { + "type": [ + "null", + "string" + ] + } + }, + "volta_shims": { + "description": "PATH-probe hits identified as Volta shims, keyed like\n[`Self::path_probe`]. Additive field (no schema bump): absent on\nhosts without Volta and on surfaces that skip shim resolution.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/VoltaShimInfo" + } + } + } + }, + "NodeVersionInfo": { + "description": "Node version declaration plus the file it came from.", + "type": "object", + "required": [ + "expected", + "source" + ], + "properties": { + "expected": { + "description": "Version string as written (e.g. `\"20.11.0\"`, `\">=18\"`).", + "type": "string" + }, + "source": { + "description": "Source file that declared the version (e.g. `\".nvmrc\"`).", + "type": "string" + } + } + }, + "OverridesView": { + "description": "Materialised override stack — the inputs that fed into resolver\ndecisions.", + "type": "object", + "required": [ + "explain", + "fallback", + "no_warnings", + "on_mismatch", + "pm", + "pm_by_ecosystem", + "prefer_runners", + "runner" + ], + "properties": { + "explain": { + "description": "Whether the explain trace is on.", + "type": "boolean" + }, + "fallback": { + "description": "Active `FallbackPolicy` label.", + "type": "string" + }, + "no_warnings": { + "description": "Whether warnings are suppressed.", + "type": "boolean" + }, + "on_mismatch": { + "description": "Active `MismatchPolicy` label.", + "type": "string" + }, + "pm": { + "description": "Cross-ecosystem PM override from `--pm` / `RUNNER_PM`.", + "anyOf": [ + { + "$ref": "#/$defs/PmOverrideInfo" + }, + { + "type": "null" + } + ] + }, + "pm_by_ecosystem": { + "description": "Per-ecosystem PM overrides from `runner.toml [pm].`.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/PmOverrideInfo" + } + }, + "prefer_runners": { + "description": "Ranked preference list from `[task_runner].prefer`.", + "type": "array", + "items": { + "type": "string" + } + }, + "runner": { + "description": "`--runner` / `RUNNER_RUNNER` override.", + "anyOf": [ + { + "$ref": "#/$defs/RunnerOverrideInfo" + }, + { + "type": "null" + } + ] + } + } + }, + "PmOverrideInfo": { + "description": "PM override + provenance.", + "type": "object", + "required": [ + "origin", + "pm" + ], + "properties": { + "origin": { + "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", + "type": "string" + }, + "pm": { + "description": "The chosen PM label.", + "type": "string" + } + } + }, + "RunnerOverrideInfo": { + "description": "Task-runner override + provenance.", + "type": "object", + "required": [ + "origin", + "runner" + ], + "properties": { + "origin": { + "description": "`\"cli\"`, `\"env\"`, or `\"config:/abs/path\"`.", + "type": "string" + }, + "runner": { + "description": "The chosen runner label.", + "type": "string" + } + } + }, + "Signals": { + "description": "Per-ecosystem signals — what the resolver had to work with.", + "type": "object", + "required": [ + "node" + ], + "properties": { + "node": { + "$ref": "#/$defs/NodeSignals", + "description": "Node-ecosystem signals. The schema is intentionally\nnode-flat today; other ecosystems get peer fields as their\nresolver paths land." + } + } + }, + "TaskInfo": { + "description": "Task entry projected into the JSON shape.", + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "alias_of": { + "description": "When the task is an alias, the target it resolves to.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "Human-readable description, if any.", + "type": [ + "null", + "string" + ] + }, + "name": { + "description": "Task name as it appears in the config.", + "type": "string" + }, + "passthrough_to": { + "description": "When the task's body is a thin wrapper for another runner.", + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + }, + "VoltaShimInfo": { + "description": "What `volta which` said about one shimmed tool.", + "type": "object", + "required": [ + "resolved" + ], + "properties": { + "resolved": { + "description": "Real provisioned binary behind the shim; `null` when Volta has\nno version of the tool (\"not provisioned\"). Shims Volta could\nnot classify at all are omitted from the map instead of guessed.", + "type": [ + "null", + "string" + ] + } + } + }, + "WarningInfo": { + "description": "Warning projected into the JSON shape. The `source`/`detail` split\nis kept stable from the pre-A4 flat-struct days so existing\nconsumers (the `doctor` test suite, ad-hoc `jq` queries) keep\nworking.", + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "description": "Human-readable detail.", + "type": "string" + }, + "source": { + "description": "Subsystem the warning came from (e.g. `\"package.json\"`).", + "type": "string" + } + } + } + }, + "title": "runner doctor --json --schema-version 2", + "description": "JSON schema for the current v2 `runner doctor --json` document. v2 uses tool-name task source labels.", + "type": "object", + "required": [ + "decisions", + "detected", + "ecosystems", + "overrides", + "root", + "schema_version", + "signals", + "warnings" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "decisions": { + "$ref": "#/$defs/Decisions", + "description": "Resolver verdict (or first-class error if the chain bailed)." + }, + "detected": { + "$ref": "#/$defs/Detected", + "description": "Raw, type-deduplicated detection results: PMs, runners, Node\nversion, monorepo flag. Stable across resolver behavior tweaks." + }, + "ecosystems": { + "description": "Detected ecosystems, in the order their package managers were\nfound by [`crate::detect`].", + "type": "array", + "items": { + "type": "string" + } + }, + "overrides": { + "$ref": "#/$defs/OverridesView", + "description": "Effective override stack — CLI, env, and config bundled." + }, + "root": { + "description": "Absolute path of the project root the report describes.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 2, + "minimum": 0, + "format": "uint32" + }, + "signals": { + "$ref": "#/$defs/Signals", + "description": "Per-ecosystem detection signals: lockfile pick, manifest\ndeclaration, PATH probe results." + }, + "tasks": { + "description": "Full task list. Subcommands that don't care omit this via\nprojection.", + "type": "array", + "items": { + "$ref": "#/$defs/TaskInfo" + } + }, + "warnings": { + "description": "Diagnostic warnings from both detection (`ctx.warnings`) and\nthe resolver (`ResolvedPm.warnings`), flattened.", + "type": "array", + "items": { + "$ref": "#/$defs/WarningInfo" + } + } + } +} diff --git a/schemas/doctor.v3-draft.schema.json b/schemas/doctor.v3-draft.schema.json new file mode 100644 index 0000000..058d8ef --- /dev/null +++ b/schemas/doctor.v3-draft.schema.json @@ -0,0 +1,880 @@ +{ + "$id": "https://kjanat.github.io/schemas/doctor.v3.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "absolute_path": { + "description": "Absolute path as emitted by the host platform. Kept intentionally permissive for POSIX, Windows, and virtual paths.", + "type": "string", + "minLength": 1 + }, + "conflict": { + "type": "object", + "required": [ + "kind", + "reason", + "selected", + "selector", + "severity", + "shadowed" + ], + "properties": { + "kind": { + "$ref": "#/$defs/non_empty_string" + }, + "reason": { + "$ref": "#/$defs/non_empty_string" + }, + "selected": { + "$ref": "#/$defs/fqn" + }, + "selector": { + "$ref": "#/$defs/non_empty_string" + }, + "severity": { + "$ref": "#/$defs/severity" + }, + "shadowed": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/fqn" + } + } + }, + "additionalProperties": false + }, + "dependency": { + "type": "object", + "required": [ + "constraints", + "kind", + "name", + "required", + "resolved" + ], + "properties": { + "constraints": { + "$ref": "#/$defs/version_constraints" + }, + "kind": { + "$ref": "#/$defs/dependency_kind" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "required": { + "type": "boolean" + }, + "resolved": { + "$ref": "#/$defs/dependency_resolution" + } + }, + "additionalProperties": false + }, + "dependency_found": { + "type": "object", + "required": [ + "found", + "satisfies" + ], + "properties": { + "found": { + "type": "boolean", + "const": true + }, + "path": { + "$ref": "#/$defs/nullable_string" + }, + "satisfies": { + "type": "boolean" + }, + "version": { + "$ref": "#/$defs/nullable_string" + }, + "via": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "dependency_kind": { + "type": "string", + "enum": [ + "binary", + "package-binary", + "runtime", + "package-manager", + "task-runner" + ] + }, + "dependency_missing": { + "type": "object", + "required": [ + "found" + ], + "properties": { + "found": { + "type": "boolean", + "const": false + }, + "via": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "dependency_resolution": { + "oneOf": [ + { + "$ref": "#/$defs/dependency_found" + }, + { + "$ref": "#/$defs/dependency_missing" + } + ] + }, + "diagnostic": { + "type": "object", + "required": [ + "code", + "message", + "severity" + ], + "properties": { + "code": { + "$ref": "#/$defs/non_empty_string" + }, + "details": { + "$ref": "#/$defs/json_value" + }, + "message": { + "$ref": "#/$defs/non_empty_string" + }, + "severity": { + "$ref": "#/$defs/severity" + }, + "source": { + "$ref": "#/$defs/nullable_string" + }, + "task": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/fqn" + } + ] + } + }, + "additionalProperties": false + }, + "ecosystem": { + "type": "object", + "required": [ + "decision", + "name", + "root", + "selected_package_manager", + "signals" + ], + "properties": { + "decision": { + "$ref": "#/$defs/ecosystem_decision" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "root": { + "$ref": "#/$defs/absolute_path" + }, + "selected_package_manager": { + "$ref": "#/$defs/nullable_string" + }, + "signals": { + "$ref": "#/$defs/signals" + } + }, + "additionalProperties": false + }, + "ecosystem_decision": { + "type": "object", + "required": [ + "confidence", + "reason", + "selected" + ], + "properties": { + "confidence": { + "type": "string", + "enum": [ + "high", + "medium", + "low", + "none" + ] + }, + "reason": { + "$ref": "#/$defs/non_empty_string" + }, + "selected": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "environment": { + "type": "object", + "required": [ + "arch", + "os", + "path_entries", + "shell" + ], + "properties": { + "arch": { + "$ref": "#/$defs/non_empty_string" + }, + "os": { + "$ref": "#/$defs/non_empty_string" + }, + "path_entries": { + "type": "array", + "items": { + "$ref": "#/$defs/absolute_path" + } + }, + "shell": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "fqn": { + "description": "Fully qualified task name: ::. The task-name segment may itself contain colons.", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*:[A-Za-z][A-Za-z0-9._-]*:.+$" + }, + "invocation": { + "type": "object", + "required": [ + "argv", + "cwd", + "started_at" + ], + "properties": { + "argv": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/non_empty_string" + } + }, + "cwd": { + "$ref": "#/$defs/absolute_path" + }, + "started_at": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "json_value": { + "description": "Arbitrary JSON value.", + "oneOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/json_value" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/json_value" + } + } + ] + }, + "non_empty_string": { + "type": "string", + "minLength": 1 + }, + "nullable_string": { + "type": [ + "null", + "string" + ] + }, + "overrides": { + "type": "object", + "required": [ + "explain", + "fallback", + "no_warnings", + "on_mismatch", + "pm", + "pm_by_ecosystem", + "prefer_runners", + "runner" + ], + "properties": { + "explain": { + "type": "boolean" + }, + "fallback": { + "type": "string", + "enum": [ + "probe", + "npm", + "error" + ] + }, + "no_warnings": { + "type": "boolean" + }, + "on_mismatch": { + "type": "string", + "enum": [ + "warn", + "error", + "ignore" + ] + }, + "pm": { + "$ref": "#/$defs/nullable_string" + }, + "pm_by_ecosystem": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/nullable_string" + } + }, + "prefer_runners": { + "type": "array", + "items": { + "$ref": "#/$defs/non_empty_string" + } + }, + "runner": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "package_identity": { + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "name": { + "$ref": "#/$defs/nullable_string" + }, + "source": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "project": { + "type": "object", + "required": [ + "monorepo", + "root", + "root_source", + "workspace" + ], + "properties": { + "monorepo": { + "type": "boolean" + }, + "root": { + "$ref": "#/$defs/absolute_path" + }, + "root_source": { + "$ref": "#/$defs/absolute_path" + }, + "workspace": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/workspace" + } + ] + } + }, + "additionalProperties": false + }, + "relative_path": { + "type": "string", + "minLength": 1 + }, + "resolution": { + "type": "object", + "required": [ + "fqn_policy", + "precedence", + "short_name_policy" + ], + "properties": { + "fqn_policy": { + "type": "string", + "enum": [ + "exact-only" + ] + }, + "precedence": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/non_empty_string" + } + }, + "short_name_policy": { + "type": "string", + "enum": [ + "deterministic-precedence", + "ambiguous-error", + "first-match" + ] + } + }, + "additionalProperties": false + }, + "runner": { + "type": "object", + "required": [ + "binary", + "name", + "schema_versions", + "version" + ], + "properties": { + "binary": { + "$ref": "#/$defs/absolute_path" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "schema_versions": { + "type": "object", + "required": [ + "doctor", + "list", + "why" + ], + "properties": { + "doctor": { + "type": "integer", + "const": 3 + }, + "list": { + "type": "integer", + "minimum": 1 + }, + "why": { + "type": "integer", + "minimum": 1 + } + }, + "additionalProperties": false + }, + "version": { + "$ref": "#/$defs/non_empty_string" + } + }, + "additionalProperties": false + }, + "severity": { + "type": "string", + "enum": [ + "debug", + "info", + "warning", + "error" + ] + }, + "signals": { + "description": "Detection evidence. Deliberately flexible because each ecosystem has different signal types.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/json_value" + } + }, + "source": { + "type": "object", + "required": [ + "exists", + "id", + "kind", + "path", + "relpath", + "scope", + "task_pointer" + ], + "properties": { + "exists": { + "type": "boolean" + }, + "id": { + "$ref": "#/$defs/source_id" + }, + "kind": { + "$ref": "#/$defs/source_kind" + }, + "package": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/package_identity" + } + ] + }, + "path": { + "$ref": "#/$defs/absolute_path" + }, + "relpath": { + "$ref": "#/$defs/relative_path" + }, + "scope": { + "$ref": "#/$defs/source_scope" + }, + "task_pointer": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "source_id": { + "type": "string", + "pattern": "^src:[A-Za-z0-9._:-]+$" + }, + "source_kind": { + "description": "Known examples: package-json, cargo-config, cargo-toml, justfile, makefile, taskfile, deno-json, pyproject-toml, mise-toml, bacon-toml.", + "type": "string", + "pattern": "^[A-Za-z][A-Za-z0-9._-]*$" + }, + "source_scope": { + "description": "Stable project-root-relative scope. Examples: root, site, crates.runner, examples.basic.", + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "task": { + "type": "object", + "required": [ + "aliases", + "cwd", + "definition", + "dependencies", + "description", + "fqn", + "name", + "resolved", + "source", + "source_pointer" + ], + "properties": { + "aliases": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/non_empty_string" + } + }, + "cwd": { + "$ref": "#/$defs/absolute_path" + }, + "definition": { + "$ref": "#/$defs/nullable_string" + }, + "dependencies": { + "type": "array", + "items": { + "$ref": "#/$defs/dependency" + } + }, + "description": { + "$ref": "#/$defs/nullable_string" + }, + "fqn": { + "$ref": "#/$defs/fqn" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "resolved": { + "$ref": "#/$defs/non_empty_string" + }, + "source": { + "$ref": "#/$defs/absolute_path" + }, + "source_pointer": { + "$ref": "#/$defs/nullable_string" + }, + "synthetic": { + "default": false, + "type": "boolean" + }, + "synthetic_reason": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "tool": { + "type": "object", + "required": [ + "id", + "kind", + "name", + "probe", + "required" + ], + "properties": { + "id": { + "$ref": "#/$defs/tool_id" + }, + "kind": { + "$ref": "#/$defs/dependency_kind" + }, + "name": { + "$ref": "#/$defs/non_empty_string" + }, + "probe": { + "$ref": "#/$defs/tool_probe" + }, + "required": { + "type": "boolean" + } + }, + "additionalProperties": false + }, + "tool_id": { + "type": "string", + "pattern": "^tool:[A-Za-z0-9._:-]+$" + }, + "tool_probe": { + "oneOf": [ + { + "$ref": "#/$defs/tool_probe_found" + }, + { + "$ref": "#/$defs/tool_probe_missing" + }, + { + "$ref": "#/$defs/tool_probe_error" + } + ] + }, + "tool_probe_error": { + "type": "object", + "required": [ + "message", + "status" + ], + "properties": { + "message": { + "$ref": "#/$defs/non_empty_string" + }, + "status": { + "type": "string", + "const": "error" + } + }, + "additionalProperties": false + }, + "tool_probe_found": { + "type": "object", + "required": [ + "path", + "status", + "version" + ], + "properties": { + "path": { + "$ref": "#/$defs/absolute_path" + }, + "status": { + "type": "string", + "const": "found" + }, + "version": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "tool_probe_missing": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "const": "missing" + } + }, + "additionalProperties": false + }, + "version_constraints": { + "type": "object", + "required": [ + "max_version", + "min_version", + "version" + ], + "properties": { + "max_version": { + "$ref": "#/$defs/nullable_string" + }, + "min_version": { + "$ref": "#/$defs/nullable_string" + }, + "version": { + "$ref": "#/$defs/nullable_string" + } + }, + "additionalProperties": false + }, + "workspace": { + "type": "object", + "required": [ + "kind", + "root", + "source" + ], + "properties": { + "kind": { + "$ref": "#/$defs/non_empty_string" + }, + "root": { + "$ref": "#/$defs/absolute_path" + }, + "source": { + "$ref": "#/$defs/absolute_path" + } + }, + "additionalProperties": false + } + }, + "title": "runner doctor --json --schema-version 3", + "description": "JSON schema for the emitted `runner doctor --json` document.", + "type": "object", + "required": [ + "$schema", + "conflicts", + "diagnostics", + "ecosystems", + "environment", + "invocation", + "kind", + "overrides", + "project", + "resolution", + "runner", + "schema_version", + "sources", + "tasks", + "tools" + ], + "properties": { + "$schema": { + "description": "Schema URI/reference for this document.", + "examples": [ + "https://kjanat.github.io/schemas/doctor.v3.schema.json", + "./doctor.v3.schema.json" + ], + "type": "string", + "format": "uri-reference" + }, + "conflicts": { + "type": "array", + "items": { + "$ref": "#/$defs/conflict" + } + }, + "diagnostics": { + "type": "array", + "items": { + "$ref": "#/$defs/diagnostic" + } + }, + "ecosystems": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/ecosystem" + } + }, + "environment": { + "$ref": "#/$defs/environment" + }, + "invocation": { + "$ref": "#/$defs/invocation" + }, + "kind": { + "type": "string", + "const": "runner.doctor" + }, + "overrides": { + "$ref": "#/$defs/overrides" + }, + "project": { + "$ref": "#/$defs/project" + }, + "resolution": { + "$ref": "#/$defs/resolution" + }, + "runner": { + "$ref": "#/$defs/runner" + }, + "schema_version": { + "type": "integer", + "const": 3 + }, + "sources": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/source" + } + }, + "tasks": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/task" + } + }, + "tools": { + "type": "array", + "uniqueItems": true, + "items": { + "$ref": "#/$defs/tool" + } + } + }, + "additionalProperties": false +} diff --git a/schemas/list.v1.example.json b/schemas/list.v1.example.json new file mode 100644 index 0000000..7b9475e --- /dev/null +++ b/schemas/list.v1.example.json @@ -0,0 +1,173 @@ +{ + "schema_version": 1, + "root": "/home/kjanat/projects/runner", + "tasks": [ + { + "name": "fmt", + "source": "package.json" + }, + { + "name": "fmt:update", + "source": "package.json" + }, + { + "name": "typecheck", + "source": "package.json" + }, + { + "name": "build-packages", + "source": "justfile" + }, + { + "name": "default", + "source": "justfile" + }, + { + "name": "gen-schema", + "source": "justfile", + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" + }, + { + "name": "ls", + "source": "justfile" + }, + { + "name": "run", + "source": "justfile" + }, + { + "name": "runner", + "source": "justfile" + }, + { + "name": "test-release", + "source": "justfile", + "description": "Build release bin and verify the facade shims spawn the native binary." + }, + { + "name": "b", + "source": "cargo", + "alias_of": "build" + }, + { + "name": "bb", + "source": "cargo", + "alias_of": "build --bin run --bin runner" + }, + { + "name": "bbr", + "source": "cargo", + "alias_of": "build --bin run --bin runner --release" + }, + { + "name": "bin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run" + }, + { + "name": "bin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "c", + "source": "cargo", + "alias_of": "check" + }, + { + "name": "cl", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features" + }, + { + "name": "comp", + "source": "cargo", + "alias_of": "run --quiet --bin runner -- completions" + }, + { + "name": "d", + "source": "cargo", + "alias_of": "doc" + }, + { + "name": "f", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "format", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "i", + "source": "cargo", + "alias_of": "install --path ." + }, + { + "name": "l", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "lint", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "man", + "source": "cargo", + "alias_of": "run --quiet --features man -- man" + }, + { + "name": "meta", + "source": "cargo", + "alias_of": "metadata --format-version 1" + }, + { + "name": "r", + "source": "cargo", + "alias_of": "run" + }, + { + "name": "rbin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run --release" + }, + { + "name": "rbin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner --release" + }, + { + "name": "rm", + "source": "cargo", + "alias_of": "remove" + }, + { + "name": "rq", + "source": "cargo", + "alias_of": "run --quiet" + }, + { + "name": "rr", + "source": "cargo", + "alias_of": "run --release" + }, + { + "name": "runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "schema", + "source": "cargo", + "alias_of": "run --quiet --features schema -- schema" + }, + { + "name": "t", + "source": "cargo", + "alias_of": "test" + } + ] +} diff --git a/schemas/list.v1.schema.json b/schemas/list.v1.schema.json new file mode 100644 index 0000000..368c4bd --- /dev/null +++ b/schemas/list.v1.schema.json @@ -0,0 +1,92 @@ +{ + "$id": "https://kjanat.github.io/schemas/list.v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "TaskInfo": { + "description": "Task entry projected into the JSON shape.", + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "alias_of": { + "description": "When the task is an alias, the target it resolves to.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "Human-readable description, if any.", + "type": [ + "null", + "string" + ] + }, + "name": { + "description": "Task name as it appears in the config.", + "type": "string" + }, + "passthrough_to": { + "description": "When the task's body is a thin wrapper for another runner.", + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "Makefile", + "justfile", + "Taskfile", + "turbo.json", + "deno.json", + "cargo", + "go", + "bacon.toml", + "mise.toml", + "pyproject.toml" + ] + } + }, + "title": "runner list --json --schema-version 1", + "description": "JSON schema for `runner list --json --schema-version 1`.", + "type": "object", + "required": [ + "root", + "schema_version", + "tasks" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "root": { + "description": "Project root.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 1, + "minimum": 0, + "format": "uint32" + }, + "tasks": { + "description": "Tasks, optionally filtered by source.", + "type": "array", + "items": { + "$ref": "#/$defs/TaskInfo" + } + } + } +} diff --git a/schemas/list.v2.example.json b/schemas/list.v2.example.json new file mode 100644 index 0000000..139e3ea --- /dev/null +++ b/schemas/list.v2.example.json @@ -0,0 +1,173 @@ +{ + "schema_version": 2, + "root": "/home/kjanat/projects/runner", + "tasks": [ + { + "name": "fmt", + "source": "package.json" + }, + { + "name": "fmt:update", + "source": "package.json" + }, + { + "name": "typecheck", + "source": "package.json" + }, + { + "name": "build-packages", + "source": "just" + }, + { + "name": "default", + "source": "just" + }, + { + "name": "gen-schema", + "source": "just", + "description": "Drift guard: just gen-schema && git diff --exit-code schemas/" + }, + { + "name": "ls", + "source": "just" + }, + { + "name": "run", + "source": "just" + }, + { + "name": "runner", + "source": "just" + }, + { + "name": "test-release", + "source": "just", + "description": "Build release bin and verify the facade shims spawn the native binary." + }, + { + "name": "b", + "source": "cargo", + "alias_of": "build" + }, + { + "name": "bb", + "source": "cargo", + "alias_of": "build --bin run --bin runner" + }, + { + "name": "bbr", + "source": "cargo", + "alias_of": "build --bin run --bin runner --release" + }, + { + "name": "bin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run" + }, + { + "name": "bin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "c", + "source": "cargo", + "alias_of": "check" + }, + { + "name": "cl", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features" + }, + { + "name": "comp", + "source": "cargo", + "alias_of": "run --quiet --bin runner -- completions" + }, + { + "name": "d", + "source": "cargo", + "alias_of": "doc" + }, + { + "name": "f", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "format", + "source": "cargo", + "alias_of": "run --quiet --bin run -- --pm npm dprint fmt" + }, + { + "name": "i", + "source": "cargo", + "alias_of": "install --path ." + }, + { + "name": "l", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "lint", + "source": "cargo", + "alias_of": "clippy --all-targets --all-features -- -D warnings -D clippy::all" + }, + { + "name": "man", + "source": "cargo", + "alias_of": "run --quiet --features man -- man" + }, + { + "name": "meta", + "source": "cargo", + "alias_of": "metadata --format-version 1" + }, + { + "name": "r", + "source": "cargo", + "alias_of": "run" + }, + { + "name": "rbin-run", + "source": "cargo", + "alias_of": "run --quiet --bin run --release" + }, + { + "name": "rbin-runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner --release" + }, + { + "name": "rm", + "source": "cargo", + "alias_of": "remove" + }, + { + "name": "rq", + "source": "cargo", + "alias_of": "run --quiet" + }, + { + "name": "rr", + "source": "cargo", + "alias_of": "run --release" + }, + { + "name": "runner", + "source": "cargo", + "alias_of": "run --quiet --bin runner" + }, + { + "name": "schema", + "source": "cargo", + "alias_of": "run --quiet --features schema -- schema" + }, + { + "name": "t", + "source": "cargo", + "alias_of": "test" + } + ] +} diff --git a/schemas/list.v2.schema.json b/schemas/list.v2.schema.json new file mode 100644 index 0000000..517e981 --- /dev/null +++ b/schemas/list.v2.schema.json @@ -0,0 +1,92 @@ +{ + "$id": "https://kjanat.github.io/schemas/list.v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "TaskInfo": { + "description": "Task entry projected into the JSON shape.", + "type": "object", + "required": [ + "name", + "source" + ], + "properties": { + "alias_of": { + "description": "When the task is an alias, the target it resolves to.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "Human-readable description, if any.", + "type": [ + "null", + "string" + ] + }, + "name": { + "description": "Task name as it appears in the config.", + "type": "string" + }, + "passthrough_to": { + "description": "When the task's body is a thin wrapper for another runner.", + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + } + } + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + } + }, + "title": "runner list --json --schema-version 2", + "description": "JSON schema for `runner list --json --schema-version 2`.", + "type": "object", + "required": [ + "root", + "schema_version", + "tasks" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "root": { + "description": "Project root.", + "type": "string" + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 2, + "minimum": 0, + "format": "uint32" + }, + "tasks": { + "description": "Tasks, optionally filtered by source.", + "type": "array", + "items": { + "$ref": "#/$defs/TaskInfo" + } + } + } +} diff --git a/schemas/runner.toml.schema.json b/schemas/runner.toml.schema.json index d928b06..4196f7c 100644 --- a/schemas/runner.toml.schema.json +++ b/schemas/runner.toml.schema.json @@ -1,35 +1,5 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "RunnerConfig", - "description": "Top-level schema for `runner.toml`.", - "type": "object", - "properties": { - "chain": { - "description": "`[chain]` — failure policy for multi-task chains.", - "$ref": "#/$defs/ChainSection" - }, - "github": { - "description": "`[github]` — GitHub Actions integration (output grouping).", - "$ref": "#/$defs/GitHubSection" - }, - "parallel": { - "description": "`[parallel]` — presentation of parallel (`-p`) chain output.", - "$ref": "#/$defs/ParallelSection" - }, - "pm": { - "description": "`[pm]` — per-ecosystem package-manager overrides.", - "$ref": "#/$defs/PmSection" - }, - "resolution": { - "description": "`[resolution]` — resolver-policy knobs.", - "$ref": "#/$defs/ResolutionSection" - }, - "task_runner": { - "description": "`[task_runner]` — task-runner preferences.", - "$ref": "#/$defs/TaskRunnerSection" - } - }, - "additionalProperties": false, "$defs": { "ChainSection": { "description": "`[chain]` section — failure policy for `run -s/-p` chains and\n`runner install `.\n\n`Option` rather than `bool` so the resolver can distinguish\n\"user explicitly set false\" from \"user didn't say\": env-overrides-\nconfig layering means `[chain].keep_going = false` plus\n`RUNNER_KEEP_GOING=1` resolves to `true`.", @@ -37,23 +7,27 @@ "properties": { "keep_going": { "description": "Run every task in the chain to completion regardless of failures.\nMutually exclusive with `kill_on_fail`. Equivalent to `-k` /\n`RUNNER_KEEP_GOING`.", + "default": null, "type": [ "boolean", "null" - ], - "default": null + ] }, "kill_on_fail": { "description": "Parallel only: terminate sibling tasks immediately on first\nfailure (forcible kill, not graceful shutdown — uncatchable on\nUnix). Mutually exclusive with `keep_going`. Equivalent to\n`--kill-on-fail` / `RUNNER_KILL_ON_FAIL`. Ignored in sequential\ncontexts.", + "default": null, "type": [ "boolean", "null" - ], - "default": null + ] } }, "additionalProperties": false, "not": { + "required": [ + "keep_going", + "kill_on_fail" + ], "properties": { "keep_going": { "const": true @@ -61,11 +35,7 @@ "kill_on_fail": { "const": true } - }, - "required": [ - "keep_going", - "kill_on_fail" - ] + } } }, "GitHubSection": { @@ -74,13 +44,13 @@ "properties": { "group_output": { "description": "Wrap task output in `runner: ` groups under GitHub Actions.\nDefaults to `true`; set `false` to restore the old ungrouped output,\nincluding the live `[task]`-prefixed muxer for parallel runs.", - "type": "boolean", - "default": true + "default": true, + "type": "boolean" }, "group_parallel": { "description": "Under GitHub Actions, group parallel (`-p`) output: buffer each task\nand print it as one block on completion instead of interleaving lines\nlive. Defaults to `true` (CI logs read better grouped), but only when\n[`Self::group_output`] is also true. The non-CI equivalent is\n`[parallel].grouped` (default `false`), so CI and local diverge unless\nyou set them to match.", - "type": "boolean", - "default": true + "default": true, + "type": "boolean" } }, "additionalProperties": false @@ -91,8 +61,8 @@ "properties": { "grouped": { "description": "Buffer each parallel task's output and print it as one contiguous\nblock the moment that task finishes (completion order — first done,\nfirst shown), instead of interleaving prefixed lines live. Defaults to\n`false` (the live `[task]`-prefixed muxer); set `true` to group even in\na plain terminal, where a colored header delimits each block.", - "type": "boolean", - "default": false + "default": false, + "type": "boolean" } }, "additionalProperties": false @@ -104,8 +74,8 @@ "node": { "description": "Package manager used to dispatch Node `package.json` scripts.\nValid values: `npm`, `pnpm`, `yarn`, `bun`, `deno`.", "type": [ - "string", - "null" + "null", + "string" ], "enum": [ "npm", @@ -119,8 +89,8 @@ "python": { "description": "Package manager used for Python ecosystems.\nValid values: `uv`, `poetry`, `pipenv`.", "type": [ - "string", - "null" + "null", + "string" ], "enum": [ "uv", @@ -139,8 +109,8 @@ "fallback": { "description": "`probe` (default) — PATH probe in canonical order when no signals\nmatch; `npm` — legacy silent fallback; `error` — refuse to proceed.", "type": [ - "string", - "null" + "null", + "string" ], "enum": [ "probe", @@ -152,8 +122,8 @@ "on_mismatch": { "description": "`warn` (default), `error`, `ignore` — how to react when declaration\n(manifest field) disagrees with detection (lockfile).", "type": [ - "string", - "null" + "null", + "string" ], "enum": [ "warn", @@ -171,8 +141,8 @@ "properties": { "prefer": { "description": "Ranked preference list. Restricts candidates to runners in the\nlist (in listed order); a same-named task under a runner not in\nthe list is hard-rejected. Parsed into [`crate::types::TaskRunner`]\nat resolver-init time so unknown labels fail fast.\n\nValid values: `turbo`, `nx`, `make`, `just`, `task`, `mise`,\n`bacon`. (Not constrained in the JSON Schema — the runtime\nparser emits a more helpful error than a schema-validation\nfailure would.)", - "type": "array", "default": [], + "type": "array", "items": { "type": "string" } @@ -180,5 +150,35 @@ }, "additionalProperties": false } - } + }, + "title": "RunnerConfig", + "description": "Top-level schema for `runner.toml`.", + "type": "object", + "properties": { + "chain": { + "$ref": "#/$defs/ChainSection", + "description": "`[chain]` — failure policy for multi-task chains." + }, + "github": { + "$ref": "#/$defs/GitHubSection", + "description": "`[github]` — GitHub Actions integration (output grouping)." + }, + "parallel": { + "$ref": "#/$defs/ParallelSection", + "description": "`[parallel]` — presentation of parallel (`-p`) chain output." + }, + "pm": { + "$ref": "#/$defs/PmSection", + "description": "`[pm]` — per-ecosystem package-manager overrides." + }, + "resolution": { + "$ref": "#/$defs/ResolutionSection", + "description": "`[resolution]` — resolver-policy knobs." + }, + "task_runner": { + "$ref": "#/$defs/TaskRunnerSection", + "description": "`[task_runner]` — task-runner preferences." + } + }, + "additionalProperties": false } diff --git a/schemas/why.v1.example.json b/schemas/why.v1.example.json new file mode 100644 index 0000000..f791bf5 --- /dev/null +++ b/schemas/why.v1.example.json @@ -0,0 +1,29 @@ +{ + "schema_version": 1, + "task": "t", + "candidates": [ + { + "source": "cargo", + "source_priority": 2, + "depth": 0, + "display_order": 6, + "is_alias": true, + "alias_of": "test", + "description": null, + "passthrough_to": null, + "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + } + ], + "selected": { + "source": "cargo", + "source_priority": 2, + "depth": 0, + "display_order": 6, + "is_alias": true, + "alias_of": "test", + "description": null, + "passthrough_to": null, + "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + }, + "pm_resolution": null +} diff --git a/schemas/why.v1.schema.json b/schemas/why.v1.schema.json new file mode 100644 index 0000000..a8a51d7 --- /dev/null +++ b/schemas/why.v1.schema.json @@ -0,0 +1,192 @@ +{ + "$id": "https://kjanat.github.io/schemas/why.v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "PmResolution": { + "anyOf": [ + { + "type": "object", + "required": [ + "pm", + "via", + "warnings" + ], + "properties": { + "pm": { + "type": "string" + }, + "via": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyWarning" + } + } + } + }, + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + ] + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "Makefile", + "justfile", + "Taskfile", + "turbo.json", + "deno.json", + "cargo", + "go", + "bacon.toml", + "mise.toml", + "pyproject.toml" + ] + }, + "WhyCandidate": { + "type": "object", + "required": [ + "alias_of", + "depth", + "description", + "display_order", + "is_alias", + "passthrough_to", + "source", + "source_dir", + "source_priority" + ], + "properties": { + "alias_of": { + "type": [ + "null", + "string" + ] + }, + "depth": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "format": "uint" + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "display_order": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "format": "uint8" + }, + "is_alias": { + "type": "boolean" + }, + "passthrough_to": { + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + }, + "source_dir": { + "type": [ + "null", + "string" + ] + }, + "source_priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "format": "uint16" + } + } + }, + "WhyWarning": { + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + }, + "title": "runner why --json --schema-version 1", + "description": "JSON schema for `runner why --json --schema-version 1`.", + "type": "object", + "required": [ + "candidates", + "pm_resolution", + "schema_version", + "selected", + "task" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyCandidate" + } + }, + "pm_resolution": { + "anyOf": [ + { + "$ref": "#/$defs/PmResolution" + }, + { + "type": "null" + } + ] + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 1, + "minimum": 0, + "format": "uint32" + }, + "selected": { + "anyOf": [ + { + "$ref": "#/$defs/WhyCandidate" + }, + { + "type": "null" + } + ] + }, + "task": { + "type": "string" + } + } +} diff --git a/schemas/why.v2.example.json b/schemas/why.v2.example.json new file mode 100644 index 0000000..363b1d0 --- /dev/null +++ b/schemas/why.v2.example.json @@ -0,0 +1,29 @@ +{ + "schema_version": 2, + "task": "t", + "candidates": [ + { + "source": "cargo", + "source_priority": 2, + "depth": 0, + "display_order": 6, + "is_alias": true, + "alias_of": "test", + "description": null, + "passthrough_to": null, + "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + } + ], + "selected": { + "source": "cargo", + "source_priority": 2, + "depth": 0, + "display_order": 6, + "is_alias": true, + "alias_of": "test", + "description": null, + "passthrough_to": null, + "source_dir": "/home/kjanat/projects/runner/.cargo/config.toml" + }, + "pm_resolution": null +} diff --git a/schemas/why.v2.schema.json b/schemas/why.v2.schema.json new file mode 100644 index 0000000..12819ca --- /dev/null +++ b/schemas/why.v2.schema.json @@ -0,0 +1,192 @@ +{ + "$id": "https://kjanat.github.io/schemas/why.v2.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$defs": { + "PmResolution": { + "anyOf": [ + { + "type": "object", + "required": [ + "pm", + "via", + "warnings" + ], + "properties": { + "pm": { + "type": "string" + }, + "via": { + "type": "string" + }, + "warnings": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyWarning" + } + } + } + }, + { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + } + ] + }, + "TaskSourceLabel": { + "type": "string", + "enum": [ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "pyproject.toml" + ] + }, + "WhyCandidate": { + "type": "object", + "required": [ + "alias_of", + "depth", + "description", + "display_order", + "is_alias", + "passthrough_to", + "source", + "source_dir", + "source_priority" + ], + "properties": { + "alias_of": { + "type": [ + "null", + "string" + ] + }, + "depth": { + "type": [ + "integer", + "null" + ], + "minimum": 0, + "format": "uint" + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "display_order": { + "type": "integer", + "minimum": 0, + "maximum": 255, + "format": "uint8" + }, + "is_alias": { + "type": "boolean" + }, + "passthrough_to": { + "type": [ + "null", + "string" + ] + }, + "source": { + "$ref": "#/$defs/TaskSourceLabel" + }, + "source_dir": { + "type": [ + "null", + "string" + ] + }, + "source_priority": { + "type": "integer", + "minimum": 0, + "maximum": 65535, + "format": "uint16" + } + } + }, + "WhyWarning": { + "type": "object", + "required": [ + "detail", + "source" + ], + "properties": { + "detail": { + "type": "string" + }, + "source": { + "type": "string" + } + } + } + }, + "title": "runner why --json --schema-version 2", + "description": "JSON schema for `runner why --json --schema-version 2`.", + "type": "object", + "required": [ + "candidates", + "pm_resolution", + "schema_version", + "selected", + "task" + ], + "properties": { + "$schema": { + "description": "URI of the JSON Schema that describes this payload.", + "type": "string" + }, + "candidates": { + "type": "array", + "items": { + "$ref": "#/$defs/WhyCandidate" + } + }, + "pm_resolution": { + "anyOf": [ + { + "$ref": "#/$defs/PmResolution" + }, + { + "type": "null" + } + ] + }, + "schema_version": { + "description": "Schema contract version for this JSON payload.", + "type": "integer", + "const": 2, + "minimum": 0, + "format": "uint32" + }, + "selected": { + "anyOf": [ + { + "$ref": "#/$defs/WhyCandidate" + }, + { + "type": "null" + } + ] + }, + "task": { + "type": "string" + } + } +} diff --git a/schemas/why.v3-draft.example.json b/schemas/why.v3-draft.example.json new file mode 100644 index 0000000..bc028e0 --- /dev/null +++ b/schemas/why.v3-draft.example.json @@ -0,0 +1,67 @@ +{ + "schema_version": 3, + "kind": "runner.why", + "root": "/home/kjanat/projects/runner", + "query": "t", + + "pm_resolution": null, + + "selected": { + "task": { + "name": "t", + "fqn": "root:cargo-alias:t", + "provider": "cargo", + "kind": "cargo-alias", + "source": "/home/kjanat/projects/runner/.cargo/config.toml", + "source_pointer": "alias.t", + "description": null, + "aliases": [], + "definition": "test", + "resolved": "cargo test", + "cwd": "/home/kjanat/projects/runner", + "dependencies": [] + }, + "match": { + "selector": "t", + "matched_by": "name", + "depth": 0, + "display_order": 6, + "source_priority": 2, + "is_alias": true, + "passthrough_to": null + } + }, + + "candidates": [ + { + "task": { + "name": "t", + "fqn": "root:cargo-alias:t", + "provider": "cargo", + "kind": "cargo-alias", + "source": "/home/kjanat/projects/runner/.cargo/config.toml", + "source_pointer": "alias.t", + "description": null, + "aliases": [], + "definition": "test", + "resolved": "cargo test", + "cwd": "/home/kjanat/projects/runner", + "dependencies": [] + }, + "match": { + "selector": "t", + "matched_by": "name", + "depth": 0, + "display_order": 6, + "source_priority": 2, + "is_alias": true, + "passthrough_to": null + } + } + ], + + "decision": { + "strategy": "single-candidate", + "reason": "exact task name matched one candidate" + } +} diff --git a/src/cli.rs b/src/cli.rs index 24c4366..394d0fa 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -991,14 +991,17 @@ pub(crate) enum Command { output: Option, }, - /// Emit the runner.toml JSON Schema (build: --features schema) + /// Emit JSON Schemas (build: --features schema) #[cfg(feature = "schema")] Schema { - /// Write the schema to this file instead of stdout. + /// Emit every committed schema into the output directory. + #[arg(long)] + all: bool, + /// Write the schema to this file, or all schemas to this directory with --all. #[arg( short = 'o', long = "output", - value_name = "FILE", + value_name = "PATH", value_hint = clap::ValueHint::FilePath, value_parser = clap::value_parser!(PathBuf), )] diff --git a/src/cmd/schema.rs b/src/cmd/schema.rs index c4bfad9..c960e0e 100644 --- a/src/cmd/schema.rs +++ b/src/cmd/schema.rs @@ -1,15 +1,110 @@ -//! `runner schema` — emit the `runner.toml` JSON Schema (feature `schema`). +//! `runner schema` — emit committed JSON Schemas (feature `schema`). use std::io::Write as _; use std::path::Path; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, bail}; +use schemars::{JsonSchema, Schema}; +use serde_json::{Map, Value, json}; -/// Write the schema to `output`, or to stdout when `None`. A trailing newline -/// is appended so the committed `schemas/*.json` ends cleanly. -pub(crate) fn write_schema(output: Option<&Path>) -> Result<()> { - let schema = crate::config_schema(); - let json = serde_json::to_string_pretty(&schema).context("failed to serialize schema")?; +use crate::schema::{Project, project::TaskListView}; + +const SCHEMA_DIR: &str = "schemas"; + +struct SchemaDocument { + filename: &'static str, + value: Value, +} + +/// Write the config schema to stdout/a file, or every committed schema to a directory. +/// A trailing newline is appended so committed `schemas/*.json` ends cleanly. +pub(crate) fn write_schema(all: bool, output: Option<&Path>) -> Result<()> { + if all { + let dir = output.unwrap_or_else(|| Path::new(SCHEMA_DIR)); + write_all_schemas(dir) + } else { + write_json( + output, + &schema_value(schemars::schema_for!(crate::config::RunnerConfig))?, + ) + } +} + +fn write_all_schemas(dir: &Path) -> Result<()> { + if dir.exists() && !dir.is_dir() { + bail!("--all output must be a directory: {}", dir.display()); + } + std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?; + + for document in schema_documents()? { + write_json(Some(&dir.join(document.filename)), &document.value)?; + } + + Ok(()) +} + +fn schema_documents() -> Result> { + Ok(vec![ + SchemaDocument { + filename: "runner.toml.schema.json", + value: schema_value(schemars::schema_for!(crate::config::RunnerConfig))?, + }, + SchemaDocument { + filename: "doctor.v1.schema.json", + value: output_schema::>("doctor", 1)?, + }, + SchemaDocument { + filename: "doctor.v2.schema.json", + value: output_schema::>("doctor", 2)?, + }, + SchemaDocument { + filename: "list.v1.schema.json", + value: output_schema::>("list", 1)?, + }, + SchemaDocument { + filename: "list.v2.schema.json", + value: output_schema::>("list", 2)?, + }, + SchemaDocument { + filename: "why.v1.schema.json", + value: output_schema::>("why", 1)?, + }, + SchemaDocument { + filename: "why.v2.schema.json", + value: output_schema::>("why", 2)?, + }, + ]) +} + +fn output_schema(command: &'static str, version: u32) -> Result { + let mut schema = serialize_schema_value::()?; + set_object_field(&mut schema, "$id", json!(schema_id(command, version))); + set_object_field(&mut schema, "title", json!(title(command, version))); + set_object_field( + &mut schema, + "description", + json!(description(command, version)), + ); + patch_schema_version_const(&mut schema, version); + patch_source_schema(&mut schema, version); + Ok(schema) +} + +fn serialize_schema_value() -> Result { + let generator = schemars::generate::SchemaSettings::default() + .for_serialize() + .into_generator(); + schema_value(generator.into_root_schema_for::()) +} + +fn schema_value(schema: Schema) -> Result { + serde_json::to_value(schema).context("failed to serialize schema") +} + +fn write_json(output: Option<&Path>, value: &Value) -> Result<()> { + let mut sorted = value.clone(); + json_schema_sort::sort_schema(&mut sorted); + let json = serde_json::to_string_pretty(&sorted).context("failed to serialize schema")?; output.map_or_else( || writeln!(std::io::stdout(), "{json}").context("failed to write schema to stdout"), |path| { @@ -18,3 +113,109 @@ pub(crate) fn write_schema(output: Option<&Path>) -> Result<()> { }, ) } + +fn set_object_field(schema: &mut Value, key: &'static str, value: Value) { + if let Some(object) = schema.as_object_mut() { + object.insert(key.to_string(), value); + } +} + +fn patch_schema_version_const(schema: &mut Value, version: u32) { + let Some(properties) = schema.get_mut("properties").and_then(Value::as_object_mut) else { + return; + }; + let Some(version_schema) = properties + .get_mut("schema_version") + .and_then(Value::as_object_mut) + else { + return; + }; + version_schema.insert("const".to_string(), json!(version)); +} + +fn patch_source_schema(schema: &mut Value, version: u32) { + let Some(defs) = schema.get_mut("$defs").and_then(Value::as_object_mut) else { + return; + }; + + defs.insert( + "TaskSourceLabel".to_string(), + task_source_label_schema(version), + ); + patch_task_info_source(defs); + patch_why_candidate_source(defs); +} + +fn patch_task_info_source(defs: &mut Map) { + patch_def_source(defs, "TaskInfo"); +} + +fn patch_why_candidate_source(defs: &mut Map) { + patch_def_source(defs, "WhyCandidate"); +} + +fn patch_def_source(defs: &mut Map, def_name: &'static str) { + let Some(source_schema) = defs + .get_mut(def_name) + .and_then(|definition| definition.get_mut("properties")) + .and_then(Value::as_object_mut) + .and_then(|properties| properties.get_mut("source")) + else { + return; + }; + *source_schema = json!({ "$ref": "#/$defs/TaskSourceLabel" }); +} + +fn task_source_label_schema(version: u32) -> Value { + json!({ "type": "string", "enum": source_labels(version) }) +} + +fn source_labels(version: u32) -> &'static [&'static str] { + match version { + 1 => &[ + "package.json", + "Makefile", + "justfile", + "Taskfile", + "turbo.json", + "deno.json", + "cargo", + "go", + "bacon.toml", + "mise.toml", + "pyproject.toml", + ], + _ => &[ + "package.json", + "make", + "just", + "task", + "turbo", + "deno", + "cargo", + "go", + "bacon", + "mise", + "pyproject.toml", + ], + } +} + +fn schema_id(command: &str, version: u32) -> String { + format!("https://kjanat.github.io/schemas/{command}.v{version}.schema.json") +} + +fn title(command: &str, version: u32) -> String { + match command { + "why" => format!("runner why --json --schema-version {version}"), + _ => format!("runner {command} --json --schema-version {version}"), + } +} + +fn description(command: &str, version: u32) -> String { + match (command, version) { + ("doctor", 1) => "JSON schema for the legacy v1 `runner doctor --json` document. v1 uses filename-style task source labels.".to_string(), + ("doctor", _) => "JSON schema for the current v2 `runner doctor --json` document. v2 uses tool-name task source labels.".to_string(), + _ => format!("JSON schema for `{}`.", title(command, version)), + } +} diff --git a/src/cmd/why.rs b/src/cmd/why.rs index ac2d3bf..d97e154 100644 --- a/src/cmd/why.rs +++ b/src/cmd/why.rs @@ -10,7 +10,7 @@ use std::path::PathBuf; use anyhow::Result; use colored::Colorize; -use serde_json::{Value, json}; +use serde::Serialize; use crate::cmd::run::{ ResolvedPythonPm, allowed_runner_sources, resolve_python_pm, runner_constraint_error, @@ -86,6 +86,61 @@ enum PmDecision { Python(Result), } +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +pub(super) struct WhyReport<'a> { + #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] + #[cfg_attr( + feature = "schema", + schemars(description = "URI of the JSON Schema that describes this payload.") + )] + schema: String, + #[cfg_attr( + feature = "schema", + schemars(description = "Schema contract version for this JSON payload.") + )] + schema_version: u32, + task: &'a str, + candidates: Vec>, + selected: Option>, + pm_resolution: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct WhyCandidate<'a> { + source: &'static str, + source_priority: u16, + depth: Option, + display_order: u8, + is_alias: bool, + alias_of: Option<&'a str>, + description: Option<&'a str>, + passthrough_to: Option<&'static str>, + source_dir: Option, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum PmResolution { + Resolved { + pm: &'static str, + via: String, + warnings: Vec, + }, + Error { + error: String, + }, +} + +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize)] +struct WhyWarning { + source: &'static str, + detail: String, +} + fn pm_decision_for_selected( ctx: &ProjectContext, overrides: &ResolutionOverrides, @@ -105,69 +160,77 @@ fn pm_decision_for_selected( } } -fn build_report( - task: &str, - candidates: &[&Task], - selected: Option<&Task>, +fn build_report<'a>( + task: &'a str, + candidates: &[&'a Task], + selected: Option<&'a Task>, pm_decision: Option<&PmDecision>, overrides: &ResolutionOverrides, ctx: &ProjectContext, schema_version: u32, -) -> Value { - json!({ - "schema_version": schema_version, - "task": task, - "candidates": candidates.iter() - .map(|c| candidate_json(c, overrides, ctx, schema_version)) +) -> WhyReport<'a> { + WhyReport { + schema: String::new(), + schema_version, + task, + candidates: candidates + .iter() + .map(|candidate| candidate_json(candidate, overrides, ctx, schema_version)) .collect::>(), - "selected": selected.map(|s| candidate_json(s, overrides, ctx, schema_version)), - "pm_resolution": pm_decision.map(pm_decision_json), - }) + selected: selected.map(|task| candidate_json(task, overrides, ctx, schema_version)), + pm_resolution: pm_decision.map(pm_resolution), + } } -fn pm_decision_json(decision: &PmDecision) -> Value { +fn pm_resolution(decision: &PmDecision) -> PmResolution { match decision { - PmDecision::Node(Ok(decision)) => json!({ - "pm": decision.pm.label(), - "via": decision.describe(), - "warnings": decision.warnings.iter().map(|w| json!({ - "source": w.source(), - "detail": w.detail(), - })).collect::>(), - }), - PmDecision::Node(Err(err)) => json!({ "error": format!("{err}") }), - PmDecision::Python(Ok(decision)) => json!({ - "pm": decision.pm.label(), - "via": decision.describe(), - "warnings": [], - }), - PmDecision::Python(Err(err)) => json!({ "error": err }), + PmDecision::Node(Ok(decision)) => PmResolution::Resolved { + pm: decision.pm.label(), + via: decision.describe(), + warnings: decision + .warnings + .iter() + .map(|warning| WhyWarning { + source: warning.source(), + detail: warning.detail(), + }) + .collect(), + }, + PmDecision::Node(Err(err)) => PmResolution::Error { + error: format!("{err}"), + }, + PmDecision::Python(Ok(decision)) => PmResolution::Resolved { + pm: decision.pm.label(), + via: decision.describe(), + warnings: Vec::new(), + }, + PmDecision::Python(Err(err)) => PmResolution::Error { error: err.clone() }, } } -fn candidate_json( - task: &Task, +fn candidate_json<'a>( + task: &'a Task, overrides: &ResolutionOverrides, ctx: &ProjectContext, schema_version: u32, -) -> Value { +) -> WhyCandidate<'a> { let depth = source_depth(ctx, task.source); - let depth_value = if depth == usize::MAX { - Value::Null + let depth = if depth == usize::MAX { + None } else { - json!(depth) + Some(depth) }; - json!({ - "source": crate::schema::labels::source_label_for(task.source, schema_version), - "source_priority": source_priority(overrides, task.source), - "depth": depth_value, - "display_order": task.source.display_order(), - "is_alias": task.alias_of.is_some(), - "alias_of": task.alias_of, - "description": task.description, - "passthrough_to": task.passthrough_to.map(crate::types::TaskRunner::label), - "source_dir": source_dir_for_task(task, ctx).map(|p| p.display().to_string()), - }) + WhyCandidate { + source: crate::schema::labels::source_label_for(task.source, schema_version), + source_priority: source_priority(overrides, task.source), + depth, + display_order: task.source.display_order(), + is_alias: task.alias_of.is_some(), + alias_of: task.alias_of.as_deref(), + description: task.description.as_deref(), + passthrough_to: task.passthrough_to.map(crate::types::TaskRunner::label), + source_dir: source_dir_for_task(task, ctx).map(|path| path.display().to_string()), + } } fn source_dir_for_task(task: &Task, ctx: &ProjectContext) -> Option { @@ -386,6 +449,7 @@ mod tests { &ctx, crate::schema::CURRENT_VERSION, ); + let report = serde_json::to_value(report).expect("why report should serialize"); assert_eq!(report["pm_resolution"]["pm"], serde_json::json!("uv")); assert!( diff --git a/src/lib.rs b/src/lib.rs index c8ae401..729e45f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -765,7 +765,7 @@ fn dispatch(cli: cli::Cli, dir: &Path) -> Result { #[cfg(feature = "man")] Some(cli::Command::Man { output }) => dispatch_man(output.as_deref()), #[cfg(feature = "schema")] - Some(cli::Command::Schema { output }) => dispatch_schema(output.as_deref()), + Some(cli::Command::Schema { all, output }) => dispatch_schema(all, output.as_deref()), Some(cli::Command::Doctor { json }) => { let schema_version = schema_version_for_json(json, cli.global.schema_version)?; cmd::doctor(&ctx, &overrides, json, schema_version)?; @@ -789,8 +789,8 @@ fn dispatch_man(output: Option<&Path>) -> Result { } #[cfg(feature = "schema")] -fn dispatch_schema(output: Option<&Path>) -> Result { - cmd::write_schema(output)?; +fn dispatch_schema(all: bool, output: Option<&Path>) -> Result { + cmd::write_schema(all, output)?; Ok(0) } diff --git a/src/schema/project.rs b/src/schema/project.rs index 61956d8..bcf6d4f 100644 --- a/src/schema/project.rs +++ b/src/schema/project.rs @@ -26,10 +26,22 @@ use crate::types::{DetectionWarning, PackageManager, ProjectContext, TaskSource} /// The canonical machine-readable view of a project, used by every /// `--json` surface. Field order is preserved by `serde_json` so /// consumers can hand-write `jq` queries without sort surprises. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Project<'a> { + /// URI of the JSON Schema that describes this payload. + #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] + #[cfg_attr( + feature = "schema", + schemars(description = "URI of the JSON Schema that describes this payload.") + )] + pub schema: String, /// Increments on any breaking change to this schema. Consumers /// should reject anything they weren't built for. + #[cfg_attr( + feature = "schema", + schemars(description = "Schema contract version for this JSON payload.") + )] pub schema_version: u32, /// Absolute path of the project root the report describes. pub root: String, @@ -120,6 +132,7 @@ impl<'a> Project<'a> { let probes = probe_signals(&ctx.root, resolve_shims); Self { + schema: String::new(), schema_version, root: ctx.root.display().to_string(), ecosystems: ctx @@ -168,6 +181,7 @@ impl<'a> Project<'a> { .filter(|t| target.is_none_or(|expected| expected == t.source)) .collect(); TaskListView { + schema: String::new(), schema_version: self.schema_version, root: self.root, tasks, @@ -177,10 +191,22 @@ impl<'a> Project<'a> { /// `list --json` projection. Same `schema_version` as [`Project`] so /// consumers can branch on it. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct TaskListView<'a> { + /// URI of the JSON Schema that describes this payload. + #[serde(rename = "$schema", skip_serializing_if = "str::is_empty")] + #[cfg_attr( + feature = "schema", + schemars(description = "URI of the JSON Schema that describes this payload.") + )] + pub schema: String, /// Identical to [`Project::schema_version`]; consumers can assume /// `1` here means a v1-shaped `tasks` array. + #[cfg_attr( + feature = "schema", + schemars(description = "Schema contract version for this JSON payload.") + )] pub schema_version: u32, /// Project root. pub root: String, @@ -190,6 +216,7 @@ pub(crate) struct TaskListView<'a> { /// Detection results — what the file scan found, before any resolver /// policy was applied. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Detected<'a> { /// Detected package managers, in detection-priority order. @@ -220,6 +247,7 @@ impl<'a> Detected<'a> { } /// Node version declaration plus the file it came from. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct NodeVersionInfo<'a> { /// Version string as written (e.g. `"20.11.0"`, `">=18"`). @@ -230,6 +258,7 @@ pub(crate) struct NodeVersionInfo<'a> { /// Materialised override stack — the inputs that fed into resolver /// decisions. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct OverridesView { /// Cross-ecosystem PM override from `--pm` / `RUNNER_PM`. @@ -282,6 +311,7 @@ impl OverridesView { } /// PM override + provenance. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct PmOverrideInfo { /// The chosen PM label. @@ -291,6 +321,7 @@ pub(crate) struct PmOverrideInfo { } /// Task-runner override + provenance. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct RunnerOverrideInfo { /// The chosen runner label. @@ -300,6 +331,7 @@ pub(crate) struct RunnerOverrideInfo { } /// Per-ecosystem signals — what the resolver had to work with. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Signals { /// Node-ecosystem signals. The schema is intentionally @@ -309,6 +341,7 @@ pub(crate) struct Signals { } /// Node-ecosystem detection signals: lockfile, manifest, PATH probe. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct NodeSignals { /// PM inferred from the highest-priority lockfile, if any. @@ -325,6 +358,7 @@ pub(crate) struct NodeSignals { } /// What `volta which` said about one shimmed tool. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct VoltaShimInfo { /// Real provisioned binary behind the shim; `null` when Volta has @@ -334,6 +368,7 @@ pub(crate) struct VoltaShimInfo { } /// Manifest-level PM declaration plus the field it came from. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct ManifestPm { /// Declared PM label. @@ -348,6 +383,7 @@ pub(crate) struct ManifestPm { /// Resolver verdict surface. Mirrors the resolver's `Result` so /// consumers can branch on the variant before reading the inner shape. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct Decisions { /// Node script-dispatch PM decision, or an error message when the @@ -358,6 +394,7 @@ pub(crate) struct Decisions { /// Either a resolved Node PM or the diagnostic string for the failure /// that prevented one. Untagged so consumers can probe via "is the /// `pm` field present?". +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] #[serde(untagged)] pub(crate) enum NodePmDecision { @@ -376,6 +413,7 @@ pub(crate) enum NodePmDecision { } /// Task entry projected into the JSON shape. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct TaskInfo<'a> { /// Task name as it appears in the config. @@ -398,6 +436,7 @@ pub(crate) struct TaskInfo<'a> { /// is kept stable from the pre-A4 flat-struct days so existing /// consumers (the `doctor` test suite, ad-hoc `jq` queries) keep /// working. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[derive(Debug, Serialize)] pub(crate) struct WarningInfo { /// Subsystem the warning came from (e.g. `"package.json"`).