From cb521cfed931dce17b247917630f7aea57e19cd6 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 19:28:29 +0900 Subject: [PATCH 1/3] Add versioned JSON output to core commands --- .github/workflows/ci.yaml | 29 + .gitignore | 1 + README.md | 4 + THIRD_PARTY_LICENSES.md | 5 +- .../share/bash-completion/completions/stack | 6 +- .../fish/vendor_completions.d/stack.fish | 3 + distribution/generated/share/man/man1/stack.1 | 16 +- .../generated/share/zsh/site-functions/_stack | 6 +- docs/machine-readable-output.md | 82 ++ examples/consume-cli-json.mjs | 42 + package-lock.json | 71 + package.json | 12 + schemas/cli-output-v1.schema.json | 274 ++++ scripts/cli-output-schema.test.mjs | 40 + scripts/validate-cli-output-schema.mjs | 29 + src/command_docs.rs | 13 +- src/lib.rs | 1265 +++++++++++++++-- src/machine_output.rs | 246 ++++ tests/cli.rs | 366 ++++- .../cli-output/check-stack-error.json | 24 + tests/fixtures/cli-output/check-success.json | 10 + tests/fixtures/cli-output/check-warning.json | 24 + .../cli-output/fmt-changes-required.json | 10 + .../cli-output/fmt-stdin-success.json | 17 + .../cli-output/operational-error.json | 13 + .../cli-output/render-file-success.json | 17 + tests/fixtures/invalid.stack | 1 + tests/fixtures/warning.stack | 1 + tests/snapshots/check-help.txt | 4 +- tests/snapshots/fmt-help.txt | 8 +- tests/snapshots/render-help.txt | 4 +- 31 files changed, 2525 insertions(+), 118 deletions(-) create mode 100644 docs/machine-readable-output.md create mode 100644 examples/consume-cli-json.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 schemas/cli-output-v1.schema.json create mode 100644 scripts/cli-output-schema.test.mjs create mode 100644 scripts/validate-cli-output-schema.mjs create mode 100644 src/machine_output.rs create mode 100644 tests/fixtures/cli-output/check-stack-error.json create mode 100644 tests/fixtures/cli-output/check-success.json create mode 100644 tests/fixtures/cli-output/check-warning.json create mode 100644 tests/fixtures/cli-output/fmt-changes-required.json create mode 100644 tests/fixtures/cli-output/fmt-stdin-success.json create mode 100644 tests/fixtures/cli-output/operational-error.json create mode 100644 tests/fixtures/cli-output/render-file-success.json create mode 100644 tests/fixtures/invalid.stack create mode 100644 tests/fixtures/warning.stack diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 434d965..11da75d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,14 @@ jobs: run: git diff --check "$(git hash-object -t tree /dev/null)" HEAD - name: Validate provider catalogs run: node scripts/validate-provider-catalogs.mjs + - name: Install JSON Schema validation dependencies + run: npm ci --ignore-scripts --no-audit + - name: Validate machine-readable output schema + run: | + node --check examples/consume-cli-json.mjs + node --check scripts/cli-output-schema.test.mjs + node --check scripts/validate-cli-output-schema.mjs + npm run test:cli-output-schema - name: Validate distribution contract run: | node scripts/validate-distribution-contract.mjs @@ -98,6 +106,10 @@ jobs: run: cargo +stable fmt --check - name: Run tests run: cargo +stable test --locked + - name: Run machine-readable consumer prototype + env: + STACK_BINARY: target/debug/stack + run: npm run test:consumer-prototype - name: Validate generated completion and manual assets shell: bash run: | @@ -196,6 +208,7 @@ jobs: test -s CONTRIBUTING.md test -s SECURITY.md test -s docs/language-server.md + test -s docs/machine-readable-output.md test -s docs/configuration.md test -s docs/completions.md test -s docs/self-update.md @@ -209,15 +222,19 @@ jobs: test -s distribution/distribution-contract.schema.json test -s distribution/install-receipt.schema.json test -s distribution/release-manifest.schema.json + test -s schemas/cli-output-v1.schema.json test -s distribution/generated/share/bash-completion/completions/stack test -s distribution/generated/share/zsh/site-functions/_stack test -s distribution/generated/share/fish/vendor_completions.d/stack.fish test -s distribution/generated/share/man/man1/stack.1 test -s Cargo.toml test -s Cargo.lock + test -s package.json + test -s package-lock.json test -s src/config.rs test -s src/command_docs.rs test -s src/lsp.rs + test -s src/machine_output.rs test -s src/update.rs test -s src/update/install.rs test -s src/update/tests.rs @@ -230,6 +247,8 @@ jobs: test -s catalogs/azure.json test -s catalogs/simple-icons.json test -s scripts/generate-provider-catalogs.mjs + test -s scripts/cli-output-schema.test.mjs + test -s scripts/validate-cli-output-schema.mjs test -s scripts/aqua-registry.test.mjs test -s scripts/sync-example-templates.mjs test -s scripts/validate-provider-catalogs.mjs @@ -260,6 +279,16 @@ jobs: test -s templates/sources/01-minimal.stack test -s tests/specification-revision test -s tests/fixtures/render.stack + test -s tests/fixtures/invalid.stack + test -s tests/fixtures/warning.stack + test -s tests/fixtures/cli-output/check-success.json + test -s tests/fixtures/cli-output/check-warning.json + test -s tests/fixtures/cli-output/check-stack-error.json + test -s tests/fixtures/cli-output/fmt-changes-required.json + test -s tests/fixtures/cli-output/fmt-stdin-success.json + test -s tests/fixtures/cli-output/render-file-success.json + test -s tests/fixtures/cli-output/operational-error.json + test -s examples/consume-cli-json.mjs test -s tests/snapshots/lsp-help.txt test -s tests/snapshots/doctor-help.txt test -s tests/snapshots/doctor-report.txt diff --git a/.gitignore b/.gitignore index b83d222..9ef6530 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target/ +/node_modules/ diff --git a/README.md b/README.md index af31231..739e71f 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,13 @@ stack init stack init --template groups-and-layout stack init --template aws-serverless-checkout -o checkout.stack stack check arch.stack +stack check arch.stack --json stack fmt arch.stack stack fmt --check arch.stack stack fmt - stack render arch.stack stack render arch.stack -o arch.svg +stack render arch.stack --json stack update --check stack lsp stack doctor @@ -61,6 +63,8 @@ stack manpage `stack check` reads the file as bytes and runs the full compiler, theme, layout, and routing validation pipeline without changing the source. Diagnostics are written to standard error in source order. Standard output remains empty. +`stack check`, `stack fmt`, and `stack render` accept `--json` for CI, editor, and agent consumers. JSON mode preserves exit statuses and emits one versioned envelope containing structured diagnostics, completed artifacts, and operational errors. Inline formatted source and SVG are carried as artifact content so standard output remains valid JSON. See the [machine-readable output contract](./docs/machine-readable-output.md) and its immutable [version 1 JSON Schema](./schemas/cli-output-v1.schema.json). + `stack fmt` uses the engine formatter and preserves comments. File mode replaces changed source atomically through a temporary file in the same directory; unchanged files are not replaced. Syntax, encoding, and host I/O failures leave the original file untouched. `stack fmt -` reads bytes from standard input and writes only canonical source to standard output. `--check` never writes source and exits with status `1` when formatting is required. `stack render` uses the same engine pipeline to produce deterministic standalone SVG. Without `-o`, standard output contains only SVG. With `-o`, the output is written atomically in the destination directory. It discovers imported `aws`, `gcp`, `azure`, and `simple-icons` packs below the shared icon store. Use `--provider-pack ` for a project-local icon-store root, and use `--notice ` to save the exact provider pack revisions, terms, source archives, and icon IDs embedded in that artifact. Pack files are bounded and validated before rendering. Diagnostics remain on standard error, warnings preserve SVG, and Stack errors never create or replace output. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index f5215a6..5c87796 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -18,7 +18,7 @@ Audit date: 2026-09-05 | `indexmap` / `hashbrown` / `equivalent` | `2.14.1`, `0.17.1`, `1.0.2` | Apache-2.0 OR MIT | , , | ZIP archive entry index. | | `cfg-if` / `cpufeatures` / `libc` | `1.0.4`, `0.3.1`, `0.2.189` | MIT OR Apache-2.0 | , , | Target selection and SHA-256 acceleration support. | | `serde` / `serde_core` | `1.0.229` | MIT OR Apache-2.0 | | Runtime catalog data types through `stack-theme`. | -| `serde_json` | `1.0.151` | MIT OR Apache-2.0 | | Embedded catalog decoding through `stack-theme`. | +| `serde_json` | `1.0.151` | MIT OR Apache-2.0 | | Embedded catalog decoding through `stack-theme` and versioned CLI JSON output serialization. | | `itoa` | `1.0.18` | MIT OR Apache-2.0 | | Transitive runtime dependency of `serde_json`. | | `memchr` | `2.8.3` | Unlicense OR MIT | | Transitive runtime dependency of `serde_json`. | | `zmij` | `1.0.23` | MIT | | Transitive runtime dependency of `serde_json`. | @@ -43,6 +43,9 @@ Audit date: 2026-09-05 | `syn` | `3.0.4` | MIT OR Apache-2.0 | | Procedural-macro build dependency; not linked into the release binary. | | `unicode-ident` | `1.0.24` | (MIT OR Apache-2.0) AND Unicode-3.0 | | Procedural-macro build dependency; not linked into the release binary. | | `cc` / `find-msvc-tools` / `shlex` | `1.4.5`, `0.1.12`, `2.0.1` | MIT OR Apache-2.0 | | Build dependencies of `ring`; not linked into the release binary. | +| `ajv` | `8.20.0` | MIT | | Lockfile-pinned JSON Schema validation for CLI output fixtures; not linked into the release binary. | +| `fast-deep-equal` / `json-schema-traverse` / `require-from-string` | `3.1.3`, `1.0.0`, `2.0.2` | MIT | , , | Build-only dependencies of `ajv`; not linked into the release binary. | +| `fast-uri` | `3.1.7` | BSD-3-Clause | | Build-only URI validation through `ajv`; not linked into the release binary. | No third-party vendor icon is bundled in the repository or binary. Provider-specific assets require a separate rights record covering source revision, copyright, trademark restrictions, modification, software redistribution, commercial diagram output, and required notices. diff --git a/distribution/generated/share/bash-completion/completions/stack b/distribution/generated/share/bash-completion/completions/stack index ab70638..c4e9a57 100644 --- a/distribution/generated/share/bash-completion/completions/stack +++ b/distribution/generated/share/bash-completion/completions/stack @@ -19,9 +19,9 @@ words="init check fmt render update lsp doctor config icons completions manpage else case "$context" in "init") words="--template -o --output --force -h --help" ;; - "check") words="-h --help" ;; - "fmt") words="- --check -h --help" ;; - "render") words="--provider-pack -o --notice -h --help" ;; + "check") words="--json -h --help" ;; + "fmt") words="- --check --json -h --help" ;; + "render") words="--provider-pack -o --notice --json -h --help" ;; "update") words="--check --version -h --help" ;; "lsp") words="-h --help" ;; "doctor") words="--provider-pack -h --help" ;; diff --git a/distribution/generated/share/fish/vendor_completions.d/stack.fish b/distribution/generated/share/fish/vendor_completions.d/stack.fish index 5e3d9a7..1438278 100644 --- a/distribution/generated/share/fish/vendor_completions.d/stack.fish +++ b/distribution/generated/share/fish/vendor_completions.d/stack.fish @@ -27,15 +27,18 @@ complete -c stack -n '__fish_seen_subcommand_from init' -l 'output' -r complete -c stack -n '__fish_seen_subcommand_from init' -l 'force' complete -c stack -n '__fish_seen_subcommand_from init' -s 'h' complete -c stack -n '__fish_seen_subcommand_from init' -l 'help' +complete -c stack -n '__fish_seen_subcommand_from check' -l 'json' complete -c stack -n '__fish_seen_subcommand_from check' -s 'h' complete -c stack -n '__fish_seen_subcommand_from check' -l 'help' complete -c stack -n '__fish_seen_subcommand_from fmt' -a '-' complete -c stack -n '__fish_seen_subcommand_from fmt' -l 'check' +complete -c stack -n '__fish_seen_subcommand_from fmt' -l 'json' complete -c stack -n '__fish_seen_subcommand_from fmt' -s 'h' complete -c stack -n '__fish_seen_subcommand_from fmt' -l 'help' complete -c stack -n '__fish_seen_subcommand_from render' -l 'provider-pack' -r complete -c stack -n '__fish_seen_subcommand_from render' -s 'o' complete -c stack -n '__fish_seen_subcommand_from render' -l 'notice' -r +complete -c stack -n '__fish_seen_subcommand_from render' -l 'json' complete -c stack -n '__fish_seen_subcommand_from render' -s 'h' complete -c stack -n '__fish_seen_subcommand_from render' -l 'help' complete -c stack -n '__fish_seen_subcommand_from update' -l 'check' diff --git a/distribution/generated/share/man/man1/stack.1 b/distribution/generated/share/man/man1/stack.1 index 230dd03..032ad63 100644 --- a/distribution/generated/share/man/man1/stack.1 +++ b/distribution/generated/share/man/man1/stack.1 @@ -87,25 +87,27 @@ Examples: Validate a Stack source file without modifying it Usage: - stack check + stack check [\-\-json] Arguments: Read Stack source bytes from this file Options: + \-\-json Write one versioned JSON envelope to standard output \-h, \-\-help Print help Examples: stack check arch.stack + stack check arch.stack \-\-json .fi .SS "stack fmt" .nf Format Stack source canonically Usage: - stack fmt - stack fmt \-\-check - stack fmt \- + stack fmt [\-\-json] + stack fmt \-\-check [\-\-json] + stack fmt \- [\-\-json] Arguments: Format the file atomically in place @@ -113,19 +115,21 @@ Arguments: Options: \-\-check Report whether formatting is required without writing output + \-\-json Write one versioned JSON envelope to standard output \-h, \-\-help Print help Examples: stack fmt arch.stack stack fmt \-\-check arch.stack stack fmt \- < input.stack > output.stack + stack fmt \-\-json \- < input.stack .fi .SS "stack render" .nf Render Stack source as standalone SVG Usage: - stack render [\-\-provider\-pack ] [\-o ] [\-\-notice ] + stack render [\-\-provider\-pack ] [\-o ] [\-\-notice ] [\-\-json] Arguments: Read Stack source bytes from this file @@ -134,6 +138,7 @@ Options: \-\-provider\-pack Read known provider packs from this icon\-store root \-o Write SVG atomically instead of using standard output \-\-notice Write exact used\-provider notices atomically + \-\-json Write one versioned JSON envelope to standard output \-h, \-\-help Print help Default icon store: @@ -143,6 +148,7 @@ Examples: stack render arch.stack stack render arch.stack \-o arch.svg stack render arch.stack \-\-notice arch.NOTICE.md \-o arch.svg + stack render arch.stack \-\-json .fi .SS "stack update" .nf diff --git a/distribution/generated/share/zsh/site-functions/_stack b/distribution/generated/share/zsh/site-functions/_stack index 88cbfcf..2629958 100644 --- a/distribution/generated/share/zsh/site-functions/_stack +++ b/distribution/generated/share/zsh/site-functions/_stack @@ -33,9 +33,9 @@ fi local -a candidates case "$context" in "init") candidates=('--template' '-o' '--output' '--force' '-h' '--help') ;; - "check") candidates=('-h' '--help') ;; - "fmt") candidates=('-' '--check' '-h' '--help') ;; - "render") candidates=('--provider-pack' '-o' '--notice' '-h' '--help') ;; + "check") candidates=('--json' '-h' '--help') ;; + "fmt") candidates=('-' '--check' '--json' '-h' '--help') ;; + "render") candidates=('--provider-pack' '-o' '--notice' '--json' '-h' '--help') ;; "update") candidates=('--check' '--version' '-h' '--help') ;; "lsp") candidates=('-h' '--help') ;; "doctor") candidates=('--provider-pack' '-h' '--help') ;; diff --git a/docs/machine-readable-output.md b/docs/machine-readable-output.md new file mode 100644 index 0000000..cef667f --- /dev/null +++ b/docs/machine-readable-output.md @@ -0,0 +1,82 @@ +# Machine-readable CLI output + +`stack check`, `stack fmt`, and `stack render` accept `--json` for CI, editor, and agent integrations. JSON mode writes exactly one newline-terminated JSON envelope to standard output and preserves the command's normal process exit status. + +```sh +stack check architecture.stack --json +stack fmt --check architecture.stack --json +stack render architecture.stack -o architecture.svg --json +``` + +Human mode is unchanged. In JSON mode, Stack diagnostics and operational failures are represented in the envelope and standard error remains empty. If the process cannot serialize or write the envelope itself, it exits `2` and reports that last-resort failure on standard error because valid JSON can no longer be guaranteed. Help remains human-readable even when `--json` is also present. + +## Version 1 envelope + +Every envelope contains the same required fields: + +```json +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "schemaVersion": 1, + "command": "check", + "outcome": "success", + "exitStatus": 0, + "diagnostics": [], + "artifacts": [], + "error": null +} +``` + +- `command` is `check`, `fmt`, or `render`. +- `outcome` is `success`, `changes-required`, `stack-error`, or `operational-error`. +- `exitStatus` is the process status: `0` for success including warnings, `1` for Stack errors or a `fmt --check` difference, and `2` for argument, host, configuration, provider-pack, engine, or internal failures. +- `diagnostics` contains portable Stack diagnostics in deterministic source order. +- `artifacts` identifies formatted source, rendered SVG, and provider notice results. +- `error` is non-null only for `operational-error`. + +Consumers should branch on `outcome`, stable diagnostic codes, artifact kinds, and operational error codes. Human-readable `message` and `help` text can improve logs but are not parsing contracts. + +## Diagnostics and ranges + +Each diagnostic includes `code`, `severity`, `message`, `path`, `range`, `expected`, `help`, and `related`. Ranges are end-exclusive. `byteOffset` is a zero-based UTF-8 byte offset; `line` and Unicode scalar `column` are one-based. Related locations use the same path and range representation. + +Paths are reported as the CLI received or derived them; they are not canonicalized. Standard-input diagnostics use ``. + +## Artifacts + +Each artifact includes `kind`, `path`, `mediaType`, and `content`: + +| Kind | File output | Standard-output result in JSON mode | +| --- | --- | --- | +| `formatted-source` | `path` identifies the formatted file and `content` is `null` | `path` is `null` and `content` contains canonical Stack source | +| `rendered-svg` | `path` identifies the written SVG and `content` is `null` | `path` is `null` and `content` contains standalone SVG | +| `provider-notice` | `path` identifies the written notice and `content` is `null` | Not applicable | + +`fmt --check` produces no artifact because it never writes formatted source. A Stack semantic error can coexist with a `formatted-source` artifact, matching human mode's existing formatter behavior. Only successfully produced artifacts are listed. + +## Operational errors + +The operational `error.code` categories are stable within schema version 1: + +| Code | Category | +| --- | --- | +| `CLI1001` | Invalid or conflicting command arguments | +| `CLI1002` | Standard-stream or filesystem I/O failure | +| `CLI1003` | Configuration or provider icon-store failure | +| `CLI1004` | Engine operational failure | +| `CLI1005` | Internal output-contract invariant failure | + +Stack source problems remain in `diagnostics` with `STK` codes and use outcome `stack-error`; they are not operational errors. + +## Compatibility and validation + +`schemaVersion: 1` and [`schemas/cli-output-v1.schema.json`](../schemas/cli-output-v1.schema.json) define an immutable envelope contract. A structural change, field removal or addition, enum expansion, type change, or semantic reinterpretation requires a new schema version and a separately named schema file. Diagnostic messages can change without a schema version change; diagnostic codes and source-range semantics follow their owning public Stack contracts. + +Consumers may vendor the schema or pin its raw GitHub URL to a reviewed commit. Repository CI validates the checked-in golden fixtures with the exact, lockfile-pinned JSON Schema validator and checks process-level output against those fixtures. + +[`examples/consume-cli-json.mjs`](../examples/consume-cli-json.mjs) is a dependency-free consumer prototype. It verifies the schema version and exit-status parity, then extracts stable summary fields without parsing messages: + +```sh +STACK_BINARY=target/debug/stack \ + node examples/consume-cli-json.mjs check tests/fixtures/render.stack +``` diff --git a/examples/consume-cli-json.mjs b/examples/consume-cli-json.mjs new file mode 100644 index 0000000..6fc78fe --- /dev/null +++ b/examples/consume-cli-json.mjs @@ -0,0 +1,42 @@ +import { spawnSync } from "node:child_process" + +const supportedCommands = new Set(["check", "fmt", "render"]) +const [command, ...arguments_] = process.argv.slice(2) +if (!supportedCommands.has(command)) { + process.stderr.write("Usage: consume-cli-json.mjs [ARGUMENTS...]\n") + process.exitCode = 2 +} else { + const binary = process.env.STACK_BINARY ?? "stack" + const completed = spawnSync(binary, [command, ...arguments_, "--json"], { + encoding: "utf8", + }) + if (completed.error) throw completed.error + if (completed.signal !== null || completed.status === null) { + throw new Error("Stack CLI did not return a process exit status") + } + if (completed.stderr !== "") { + throw new Error("Stack CLI emitted unexpected standard error in JSON mode") + } + + const envelope = JSON.parse(completed.stdout) + if (envelope.schemaVersion !== 1) { + throw new Error(`Unsupported Stack CLI schema version: ${envelope.schemaVersion}`) + } + if (envelope.command !== command) { + throw new Error(`Expected ${command} output, received ${envelope.command}`) + } + if (envelope.exitStatus !== completed.status) { + throw new Error("Envelope exitStatus does not match the process exit status") + } + + const summary = { + command: envelope.command, + outcome: envelope.outcome, + exitStatus: envelope.exitStatus, + diagnosticCodes: envelope.diagnostics.map(({ code }) => code), + artifacts: envelope.artifacts.map(({ kind, path }) => ({ kind, path })), + errorCode: envelope.error?.code ?? null, + } + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`) + process.exitCode = completed.status +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..64e6ae8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,71 @@ +{ + "name": "stack-cli-repository-checks", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stack-cli-repository-checks", + "devDependencies": { + "ajv": "8.20.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7861baa --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "stack-cli-repository-checks", + "private": true, + "type": "module", + "scripts": { + "test:cli-output-schema": "node scripts/validate-cli-output-schema.mjs && node --test scripts/cli-output-schema.test.mjs", + "test:consumer-prototype": "node examples/consume-cli-json.mjs check tests/fixtures/render.stack" + }, + "devDependencies": { + "ajv": "8.20.0" + } +} diff --git a/schemas/cli-output-v1.schema.json b/schemas/cli-output-v1.schema.json new file mode 100644 index 0000000..e94198b --- /dev/null +++ b/schemas/cli-output-v1.schema.json @@ -0,0 +1,274 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "title": "Stack CLI machine-readable output", + "description": "Version 1 output envelope for stack check, fmt, and render --json.", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "command", + "outcome", + "exitStatus", + "diagnostics", + "artifacts", + "error" + ], + "properties": { + "$schema": { + "const": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json" + }, + "schemaVersion": { "const": 1 }, + "command": { "enum": ["check", "fmt", "render"] }, + "outcome": { + "enum": ["success", "changes-required", "stack-error", "operational-error"] + }, + "exitStatus": { "enum": [0, 1, 2] }, + "diagnostics": { + "type": "array", + "items": { "$ref": "#/$defs/diagnostic" } + }, + "artifacts": { + "type": "array", + "items": { "$ref": "#/$defs/artifact" } + }, + "error": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/operationalError" } + ] + } + }, + "allOf": [ + { + "if": { + "properties": { "outcome": { "const": "success" } }, + "required": ["outcome"] + }, + "then": { + "properties": { + "exitStatus": { "const": 0 }, + "diagnostics": { + "not": { + "type": "array", + "contains": { + "type": "object", + "properties": { "severity": { "const": "error" } }, + "required": ["severity"] + } + } + }, + "error": { "type": "null" } + } + } + }, + { + "if": { + "properties": { "outcome": { "const": "changes-required" } }, + "required": ["outcome"] + }, + "then": { + "properties": { + "command": { "const": "fmt" }, + "exitStatus": { "const": 1 }, + "diagnostics": { + "not": { + "type": "array", + "contains": { + "type": "object", + "properties": { "severity": { "const": "error" } }, + "required": ["severity"] + } + } + }, + "error": { "type": "null" } + } + } + }, + { + "if": { + "properties": { "outcome": { "const": "stack-error" } }, + "required": ["outcome"] + }, + "then": { + "properties": { + "exitStatus": { "const": 1 }, + "diagnostics": { + "type": "array", + "contains": { + "type": "object", + "properties": { "severity": { "const": "error" } }, + "required": ["severity"] + } + }, + "error": { "type": "null" } + } + } + }, + { + "if": { + "properties": { "outcome": { "const": "operational-error" } }, + "required": ["outcome"] + }, + "then": { + "properties": { + "exitStatus": { "const": 2 }, + "error": { "$ref": "#/$defs/operationalError" } + } + } + } + ], + "$defs": { + "position": { + "type": "object", + "additionalProperties": false, + "required": ["byteOffset", "line", "column"], + "properties": { + "byteOffset": { "type": "integer", "minimum": 0 }, + "line": { "type": "integer", "minimum": 1 }, + "column": { "type": "integer", "minimum": 1 } + } + }, + "range": { + "type": "object", + "additionalProperties": false, + "required": ["start", "end"], + "properties": { + "start": { "$ref": "#/$defs/position" }, + "end": { "$ref": "#/$defs/position" } + } + }, + "relatedInformation": { + "type": "object", + "additionalProperties": false, + "required": ["message", "path", "range"], + "properties": { + "message": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "range": { "$ref": "#/$defs/range" } + } + }, + "diagnostic": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "severity", + "message", + "path", + "range", + "expected", + "help", + "related" + ], + "properties": { + "code": { "type": "string", "pattern": "^STK[0-9]{4}$" }, + "severity": { "enum": ["error", "warning"] }, + "message": { "type": "string", "minLength": 1 }, + "path": { "type": "string", "minLength": 1 }, + "range": { "$ref": "#/$defs/range" }, + "expected": { + "type": "array", + "items": { "type": "string" } + }, + "help": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1 } + ] + }, + "related": { + "type": "array", + "items": { "$ref": "#/$defs/relatedInformation" } + } + } + }, + "artifact": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "path", "mediaType", "content"], + "properties": { + "kind": { "const": "formatted-source" }, + "path": { "$ref": "#/$defs/nullablePath" }, + "mediaType": { "const": "text/vnd.stack" }, + "content": { "$ref": "#/$defs/nullableContent" } + }, + "oneOf": [ + { + "properties": { + "path": { "type": "string", "minLength": 1 }, + "content": { "type": "null" } + } + }, + { + "properties": { + "path": { "type": "null" }, + "content": { "type": "string" } + } + } + ] + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "path", "mediaType", "content"], + "properties": { + "kind": { "const": "rendered-svg" }, + "path": { "$ref": "#/$defs/nullablePath" }, + "mediaType": { "const": "image/svg+xml" }, + "content": { "$ref": "#/$defs/nullableContent" } + }, + "oneOf": [ + { + "properties": { + "path": { "type": "string", "minLength": 1 }, + "content": { "type": "null" } + } + }, + { + "properties": { + "path": { "type": "null" }, + "content": { "type": "string", "minLength": 1 } + } + } + ] + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "path", "mediaType", "content"], + "properties": { + "kind": { "const": "provider-notice" }, + "path": { "type": "string", "minLength": 1 }, + "mediaType": { "const": "text/markdown" }, + "content": { "type": "null" } + } + } + ] + }, + "nullablePath": { + "oneOf": [ + { "type": "null" }, + { "type": "string", "minLength": 1 } + ] + }, + "nullableContent": { + "oneOf": [ + { "type": "null" }, + { "type": "string" } + ] + }, + "operationalError": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message"], + "properties": { + "code": { "enum": ["CLI1001", "CLI1002", "CLI1003", "CLI1004", "CLI1005"] }, + "message": { "type": "string", "minLength": 1 } + } + } + } +} diff --git a/scripts/cli-output-schema.test.mjs b/scripts/cli-output-schema.test.mjs new file mode 100644 index 0000000..b4fc950 --- /dev/null +++ b/scripts/cli-output-schema.test.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import path from "node:path" +import test from "node:test" +import { fileURLToPath } from "node:url" + +import Ajv2020 from "ajv/dist/2020.js" + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const schema = JSON.parse( + fs.readFileSync(path.join(repositoryRoot, "schemas/cli-output-v1.schema.json"), "utf8"), +) +const valid = JSON.parse( + fs.readFileSync( + path.join(repositoryRoot, "tests/fixtures/cli-output/check-success.json"), + "utf8", + ), +) +const validate = new Ajv2020({ allErrors: true, strict: true }).compile(schema) + +function changed(change) { + const candidate = structuredClone(valid) + change(candidate) + return candidate +} + +test("schema version 1 accepts its stable success envelope", () => { + assert.equal(validate(valid), true) +}) + +test("schema version and unknown fields cannot drift within version 1", () => { + assert.equal(validate(changed((value) => (value.schemaVersion = 2))), false) + assert.equal(validate(changed((value) => (value.newField = true))), false) +}) + +test("outcome, exit status, diagnostics, and error remain coherent", () => { + assert.equal(validate(changed((value) => (value.exitStatus = 1))), false) + assert.equal(validate(changed((value) => (value.outcome = "stack-error"))), false) + assert.equal(validate(changed((value) => (value.outcome = "operational-error"))), false) +}) diff --git a/scripts/validate-cli-output-schema.mjs b/scripts/validate-cli-output-schema.mjs new file mode 100644 index 0000000..f97630f --- /dev/null +++ b/scripts/validate-cli-output-schema.mjs @@ -0,0 +1,29 @@ +import fs from "node:fs" +import path from "node:path" +import { fileURLToPath } from "node:url" + +import Ajv2020 from "ajv/dist/2020.js" + +const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const schemaPath = path.join(repositoryRoot, "schemas/cli-output-v1.schema.json") +const fixtureDirectory = path.join(repositoryRoot, "tests/fixtures/cli-output") +const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8")) +const ajv = new Ajv2020({ allErrors: true, strict: true }) +const validate = ajv.compile(schema) +const fixtureNames = fs + .readdirSync(fixtureDirectory) + .filter((name) => name.endsWith(".json")) + .sort() + +if (fixtureNames.length === 0) { + throw new Error("No CLI output fixtures were found") +} + +for (const fixtureName of fixtureNames) { + const fixture = JSON.parse(fs.readFileSync(path.join(fixtureDirectory, fixtureName), "utf8")) + if (!validate(fixture)) { + throw new Error(`${fixtureName}: ${ajv.errorsText(validate.errors)}`) + } +} + +process.stdout.write(`Validated ${fixtureNames.length} CLI output fixtures against schema version 1.\n`) diff --git a/src/command_docs.rs b/src/command_docs.rs index 94753be..d68e47c 100644 --- a/src/command_docs.rs +++ b/src/command_docs.rs @@ -57,19 +57,26 @@ const COMMANDS: &[CommandSpec] = &[ CommandSpec { context: "check", description: "Validate a Stack source file without modifying it", - options: &["-h", "--help"], + options: &["--json", "-h", "--help"], values: &[], }, CommandSpec { context: "fmt", description: "Format a file in place or read from standard input", - options: &["--check", "-h", "--help"], + options: &["--check", "--json", "-h", "--help"], values: &["-"], }, CommandSpec { context: "render", description: "Render standalone SVG to standard output or a file", - options: &["--provider-pack", "-o", "--notice", "-h", "--help"], + options: &[ + "--provider-pack", + "-o", + "--notice", + "--json", + "-h", + "--help", + ], values: &[], }, CommandSpec { diff --git a/src/lib.rs b/src/lib.rs index e6280f7..3ebb24a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,6 +17,7 @@ use stack_engine::{ mod command_docs; mod config; mod lsp; +mod machine_output; mod provider; mod provider_catalog; mod templates; @@ -106,24 +107,26 @@ const CHECK_HELP: &str = "\ Validate a Stack source file without modifying it Usage: - stack check + stack check [--json] Arguments: Read Stack source bytes from this file Options: + --json Write one versioned JSON envelope to standard output -h, --help Print help Examples: stack check arch.stack + stack check arch.stack --json "; const FORMAT_HELP: &str = "\ Format Stack source canonically Usage: - stack fmt - stack fmt --check - stack fmt - + stack fmt [--json] + stack fmt --check [--json] + stack fmt - [--json] Arguments: Format the file atomically in place @@ -131,18 +134,20 @@ Arguments: Options: --check Report whether formatting is required without writing output + --json Write one versioned JSON envelope to standard output -h, --help Print help Examples: stack fmt arch.stack stack fmt --check arch.stack stack fmt - < input.stack > output.stack + stack fmt --json - < input.stack "; const RENDER_HELP: &str = "\ Render Stack source as standalone SVG Usage: - stack render [--provider-pack ] [-o ] [--notice ] + stack render [--provider-pack ] [-o ] [--notice ] [--json] Arguments: Read Stack source bytes from this file @@ -151,6 +156,7 @@ Options: --provider-pack Read known provider packs from this icon-store root -o Write SVG atomically instead of using standard output --notice Write exact used-provider notices atomically + --json Write one versioned JSON envelope to standard output -h, --help Print help Default icon store: @@ -160,6 +166,7 @@ Examples: stack render arch.stack stack render arch.stack -o arch.svg stack render arch.stack --notice arch.NOTICE.md -o arch.svg + stack render arch.stack --json "; const LSP_HELP: &str = "\ Run the Stack language server over standard input and output @@ -409,6 +416,109 @@ enum FormatMode { Check, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum OutputMode { + Human, + Json, +} + +struct OutputArguments { + mode: OutputMode, + values: Vec, + duplicate_json: bool, +} + +fn output_arguments(arguments: impl Iterator) -> OutputArguments { + let mut mode = OutputMode::Human; + let mut values = Vec::new(); + let mut duplicate_json = false; + for argument in arguments { + if argument == OsStr::new("--json") { + if mode == OutputMode::Json { + duplicate_json = true; + } + mode = OutputMode::Json; + } else { + values.push(argument); + } + } + OutputArguments { + mode, + values, + duplicate_json, + } +} + +fn output_argument_error( + command: machine_output::Command, + mode: OutputMode, + message: &str, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + match mode { + OutputMode::Human => argument_error(message, stderr), + OutputMode::Json => output_operational_error( + command, + mode, + machine_output::ARGUMENT_ERROR, + message.to_owned(), + Path::new(""), + &[], + Vec::new(), + stdout, + stderr, + ), + } +} + +#[allow(clippy::too_many_arguments)] +fn output_operational_error( + command: machine_output::Command, + mode: OutputMode, + code: &'static str, + message: String, + diagnostics_path: &Path, + diagnostics: &[Diagnostic], + artifacts: Vec, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + match mode { + OutputMode::Human => write_stderr_error(&message, stderr), + OutputMode::Json => write_machine_output( + machine_output::Envelope::operational_error( + command, + code, + message, + diagnostics_path, + diagnostics, + artifacts, + ), + stdout, + stderr, + ), + } +} + +fn write_machine_output( + envelope: machine_output::Envelope, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let exit_status = envelope.exit_status(); + let mut bytes = match serde_json::to_vec(&envelope) { + Ok(bytes) => bytes, + Err(_) => return write_stderr_error("cannot serialize JSON output", stderr), + }; + bytes.push(b'\n'); + if stdout.write_all(&bytes).is_ok() { + exit_status + } else { + write_stderr_error("cannot write JSON output", stderr) + } +} + #[derive(Debug, PartialEq, Eq)] enum RenderDestination { Stdout, @@ -1456,25 +1566,49 @@ fn download_provider_archive(url: &str, limit: u64) -> Result, String> { } fn run_render( - mut arguments: impl Iterator, + arguments: impl Iterator, stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> u8 { + let parsed = output_arguments(arguments); + let mode = parsed.mode; + if parsed.duplicate_json { + return output_argument_error( + machine_output::Command::Render, + mode, + "duplicate '--json' option", + stdout, + stderr, + ); + } + let mut arguments = parsed.values.into_iter(); let Some(source) = arguments.next() else { - return argument_error("missing file for 'stack render'", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "missing file for 'stack render'", + stdout, + stderr, + ); }; if source == OsStr::new("--help") || source == OsStr::new("-h") { if let Some(extra) = arguments.next() { - return argument_error( + return output_argument_error( + machine_output::Command::Render, + mode, &format!("unexpected argument '{}'", extra.to_string_lossy()), + stdout, stderr, ); } return write_stdout(RENDER_HELP, stdout, stderr); } if source.to_string_lossy().starts_with('-') { - return argument_error( + return output_argument_error( + machine_output::Command::Render, + mode, &format!("unknown option '{}'", source.to_string_lossy()), + stdout, stderr, ); } @@ -1485,31 +1619,70 @@ fn run_render( while let Some(option) = arguments.next() { if option == OsStr::new("-o") { if destination.is_some() { - return argument_error("duplicate '-o' option", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "duplicate '-o' option", + stdout, + stderr, + ); } let Some(output) = arguments.next() else { - return argument_error("missing output file after '-o'", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "missing output file after '-o'", + stdout, + stderr, + ); }; destination = Some(RenderDestination::File(PathBuf::from(output))); } else if option == OsStr::new("--notice") { if notice_path.is_some() { - return argument_error("duplicate '--notice' option", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "duplicate '--notice' option", + stdout, + stderr, + ); } let Some(path) = arguments.next() else { - return argument_error("missing notice file after '--notice'", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "missing notice file after '--notice'", + stdout, + stderr, + ); }; notice_path = Some(PathBuf::from(path)); } else if option == OsStr::new("--provider-pack") { if provider_pack_root.is_some() { - return argument_error("duplicate '--provider-pack' option", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "duplicate '--provider-pack' option", + stdout, + stderr, + ); } let Some(path) = arguments.next() else { - return argument_error("missing provider icon-store directory", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "missing provider icon-store directory", + stdout, + stderr, + ); }; provider_pack_root = Some(PathBuf::from(path)); } else { - return argument_error( + return output_argument_error( + machine_output::Command::Render, + mode, &format!("unexpected argument '{}'", option.to_string_lossy()), + stdout, stderr, ); } @@ -1517,16 +1690,34 @@ fn run_render( let destination = destination.unwrap_or(RenderDestination::Stdout); if matches!(&destination, RenderDestination::File(path) if path.as_os_str() == source) { - return argument_error("input and output files must be different", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "input and output files must be different", + stdout, + stderr, + ); } if notice_path .as_ref() .is_some_and(|path| path.as_os_str() == source) { - return argument_error("input and notice files must be different", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "input and notice files must be different", + stdout, + stderr, + ); } if matches!(&destination, RenderDestination::File(path) if notice_path.as_ref() == Some(path)) { - return argument_error("output and notice files must be different", stderr); + return output_argument_error( + machine_output::Command::Render, + mode, + "output and notice files must be different", + stdout, + stderr, + ); } let explicit_provider_pack_root = provider_pack_root.is_some(); @@ -1534,33 +1725,77 @@ fn run_render( let provider_pack_root = match config::icon_store_root(provider_pack_root.as_deref(), &environment) { Ok(path) => path, - Err(error) => return write_stderr_error(&error, stderr), + Err(error) => { + return output_operational_error( + machine_output::Command::Render, + mode, + machine_output::CONFIGURATION_ERROR, + error, + Path::new(&source), + &[], + Vec::new(), + stdout, + stderr, + ); + } }; - render_file( - Path::new(&source), - destination, - &provider_pack_root, - !explicit_provider_pack_root, - notice_path.as_deref(), - stdout, - stderr, - ) + match mode { + OutputMode::Human => render_file( + Path::new(&source), + destination, + &provider_pack_root, + !explicit_provider_pack_root, + notice_path.as_deref(), + stdout, + stderr, + ), + OutputMode::Json => render_file_json( + Path::new(&source), + destination, + &provider_pack_root, + !explicit_provider_pack_root, + notice_path.as_deref(), + stdout, + stderr, + ), + } } fn run_format( - mut arguments: impl Iterator, + arguments: impl Iterator, stdin: &mut dyn Read, stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> u8 { + let parsed = output_arguments(arguments); + let output_mode = parsed.mode; + if parsed.duplicate_json { + return output_argument_error( + machine_output::Command::Fmt, + output_mode, + "duplicate '--json' option", + stdout, + stderr, + ); + } + let mut arguments = parsed.values.into_iter(); let Some(first) = arguments.next() else { - return argument_error("missing file for 'stack fmt'", stderr); + return output_argument_error( + machine_output::Command::Fmt, + output_mode, + "missing file for 'stack fmt'", + stdout, + stderr, + ); }; if first == OsStr::new("--help") || first == OsStr::new("-h") { if let Some(extra) = arguments.next() { - return argument_error( + return output_argument_error( + machine_output::Command::Fmt, + output_mode, &format!("unexpected argument '{}'", extra.to_string_lossy()), + stdout, stderr, ); } @@ -1569,45 +1804,79 @@ fn run_format( let (mode, input) = if first == OsStr::new("--check") { let Some(input) = arguments.next() else { - return argument_error("missing file for 'stack fmt --check'", stderr); + return output_argument_error( + machine_output::Command::Fmt, + output_mode, + "missing file for 'stack fmt --check'", + stdout, + stderr, + ); }; (FormatMode::Check, input) } else { (FormatMode::Write, first) }; if input != OsStr::new("-") && input.to_string_lossy().starts_with('-') { - return argument_error( + return output_argument_error( + machine_output::Command::Fmt, + output_mode, &format!("unknown option '{}'", input.to_string_lossy()), + stdout, stderr, ); } if let Some(extra) = arguments.next() { - return argument_error( + return output_argument_error( + machine_output::Command::Fmt, + output_mode, &format!("unexpected argument '{}'", extra.to_string_lossy()), + stdout, stderr, ); } - if input == OsStr::new("-") { - format_stdin(mode, stdin, stdout, stderr) - } else { - format_file(mode, Path::new(&input), stderr) + match (output_mode, input == OsStr::new("-")) { + (OutputMode::Human, true) => format_stdin(mode, stdin, stdout, stderr), + (OutputMode::Human, false) => format_file(mode, Path::new(&input), stderr), + (OutputMode::Json, true) => format_stdin_json(mode, stdin, stdout, stderr), + (OutputMode::Json, false) => format_file_json(mode, Path::new(&input), stdout, stderr), } } fn run_check( - mut arguments: impl Iterator, + arguments: impl Iterator, stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> u8 { + let parsed = output_arguments(arguments); + let mode = parsed.mode; + if parsed.duplicate_json { + return output_argument_error( + machine_output::Command::Check, + mode, + "duplicate '--json' option", + stdout, + stderr, + ); + } + let mut arguments = parsed.values.into_iter(); let Some(path) = arguments.next() else { - return argument_error("missing file for 'stack check'", stderr); + return output_argument_error( + machine_output::Command::Check, + mode, + "missing file for 'stack check'", + stdout, + stderr, + ); }; if path == OsStr::new("--help") || path == OsStr::new("-h") { if let Some(extra) = arguments.next() { - return argument_error( + return output_argument_error( + machine_output::Command::Check, + mode, &format!("unexpected argument '{}'", extra.to_string_lossy()), + stdout, stderr, ); } @@ -1615,13 +1884,19 @@ fn run_check( } if let Some(extra) = arguments.next() { - return argument_error( + return output_argument_error( + machine_output::Command::Check, + mode, &format!("unexpected argument '{}'", extra.to_string_lossy()), + stdout, stderr, ); } - check_file(Path::new(&path), stderr) + match mode { + OutputMode::Human => check_file(Path::new(&path), stderr), + OutputMode::Json => check_file_json(Path::new(&path), stdout, stderr), + } } fn check_file(path: &Path, stderr: &mut dyn Write) -> u8 { @@ -1668,6 +1943,77 @@ fn check_file_with( } } +fn check_file_json(path: &Path, stdout: &mut dyn Write, stderr: &mut dyn Write) -> u8 { + check_file_json_with(path, stdout, stderr, |source| { + Engine::bundled().check(source) + }) +} + +fn check_file_json_with( + path: &Path, + stdout: &mut dyn Write, + stderr: &mut dyn Write, + check: impl FnOnce(&[u8]) -> Result, +) -> u8 { + let source = match fs::read(path) { + Ok(source) => source, + Err(error) => { + return output_operational_error( + machine_output::Command::Check, + OutputMode::Json, + machine_output::IO_ERROR, + format!( + "cannot read '{}': {}", + path.display(), + stable_io_error(error.kind()) + ), + path, + &[], + Vec::new(), + stdout, + stderr, + ); + } + }; + let output = match check(&source) { + Ok(output) => output, + Err(error) => { + return output_operational_error( + machine_output::Command::Check, + OutputMode::Json, + machine_output::ENGINE_ERROR, + format!("cannot check '{}': {error}", path.display()), + path, + &[], + Vec::new(), + stdout, + stderr, + ); + } + }; + let has_errors = output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error); + let (outcome, exit_status) = if has_errors { + (machine_output::Outcome::StackError, EXIT_STACK_ERROR) + } else { + (machine_output::Outcome::Success, EXIT_SUCCESS) + }; + write_machine_output( + machine_output::Envelope::result( + machine_output::Command::Check, + outcome, + exit_status, + path, + &output.diagnostics, + Vec::new(), + ), + stdout, + stderr, + ) +} + fn render_file( path: &Path, destination: RenderDestination, @@ -1707,37 +2053,64 @@ fn render_file( ) } -fn load_provider_store(root: &Path, allow_missing: bool) -> Result, String> { - let metadata = match fs::symlink_metadata(root) { - Ok(metadata) => metadata, - Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => { - return Ok(Vec::new()); - } - Err(error) => return Err(stable_io_error(error.kind()).to_owned()), - }; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err("store path must be a real directory, not a symlink".to_owned()); - } - - let mut packs = Vec::new(); - for provider_id in provider_catalog::PROVIDER_IDS { - let pack_root = root.join(provider_id); - match fs::symlink_metadata(&pack_root) { - Ok(_) => packs.push(load_provider_pack_for(&pack_root, provider_id)?), - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => { - return Err(format!( - "cannot inspect '{}': {}", - pack_root.display(), - stable_io_error(error.kind()) - )); +fn render_file_json( + path: &Path, + destination: RenderDestination, + provider_pack_root: &Path, + allow_missing_provider_pack_root: bool, + notice_path: Option<&Path>, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let provider_packs = + match load_provider_store(provider_pack_root, allow_missing_provider_pack_root) { + Ok(provider_packs) => provider_packs, + Err(reason) => { + return output_operational_error( + machine_output::Command::Render, + OutputMode::Json, + machine_output::CONFIGURATION_ERROR, + format!( + "cannot load provider icon store '{}': {reason}", + provider_pack_root.display() + ), + path, + &[], + Vec::new(), + stdout, + stderr, + ); } + }; + let engine = match Engine::with_provider_packs(&provider_packs) { + Ok(engine) => engine, + Err(error) => { + return output_operational_error( + machine_output::Command::Render, + OutputMode::Json, + machine_output::ENGINE_ERROR, + format!("cannot load provider packs: {error}"), + path, + &[], + Vec::new(), + stdout, + stderr, + ); } - } - Ok(packs) + }; + render_file_json_with( + path, + destination, + notice_path, + stdout, + stderr, + |source| engine.render(source), + atomic_write_output, + ) } -fn render_file_with( +#[allow(clippy::too_many_arguments)] +fn render_file_json_with( path: &Path, destination: RenderDestination, notice_path: Option<&Path>, @@ -1749,12 +2122,19 @@ fn render_file_with( let source = match fs::read(path) { Ok(source) => source, Err(error) => { - return write_stderr_error( - &format!( + return output_operational_error( + machine_output::Command::Render, + OutputMode::Json, + machine_output::IO_ERROR, + format!( "cannot read '{}': {}", path.display(), stable_io_error(error.kind()) ), + path, + &[], + Vec::new(), + stdout, stderr, ); } @@ -1762,65 +2142,229 @@ fn render_file_with( let output = match render(&source) { Ok(output) => output, Err(error) => { - return write_stderr_error( - &format!("cannot render '{}': {error}", path.display()), + return output_operational_error( + machine_output::Command::Render, + OutputMode::Json, + machine_output::ENGINE_ERROR, + format!("cannot render '{}': {error}", path.display()), + path, + &[], + Vec::new(), + stdout, stderr, ); } }; - let has_errors = match write_diagnostics(path, &output.diagnostics, stderr) { - Ok(has_errors) => has_errors, - Err(()) => return EXIT_USAGE_OR_IO, - }; + let has_errors = output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error); if has_errors { - return EXIT_STACK_ERROR; + return write_machine_output( + machine_output::Envelope::result( + machine_output::Command::Render, + machine_output::Outcome::StackError, + EXIT_STACK_ERROR, + path, + &output.diagnostics, + Vec::new(), + ), + stdout, + stderr, + ); } let Some(svg) = output.svg else { - return write_stderr_error("renderer produced no SVG or error diagnostic", stderr); + return output_operational_error( + machine_output::Command::Render, + OutputMode::Json, + machine_output::INTERNAL_ERROR, + "renderer produced no SVG or error diagnostic".to_owned(), + path, + &output.diagnostics, + Vec::new(), + stdout, + stderr, + ); }; + let mut artifacts = Vec::new(); match destination { RenderDestination::Stdout => { - if stdout.write_all(svg.as_bytes()).is_err() { - return write_stderr_error("cannot write rendered SVG", stderr); - } + artifacts.push(machine_output::Artifact::rendered_svg(None, Some(svg))); } RenderDestination::File(output_path) => { if let Err(error) = write_output(&output_path, svg.as_bytes()) { - return write_stderr_error( - &format!( + return output_operational_error( + machine_output::Command::Render, + OutputMode::Json, + machine_output::IO_ERROR, + format!( "cannot write '{}': {}", output_path.display(), stable_io_error(error.kind()) ), + path, + &output.diagnostics, + artifacts, + stdout, stderr, ); } + artifacts.push(machine_output::Artifact::rendered_svg( + Some(&output_path), + None, + )); } } if let Some(notice_path) = notice_path { let notice = render_provider_notices(&output.provider_notices); if let Err(error) = write_output(notice_path, notice.as_bytes()) { - return write_stderr_error( - &format!( + return output_operational_error( + machine_output::Command::Render, + OutputMode::Json, + machine_output::IO_ERROR, + format!( "cannot write '{}': {}", notice_path.display(), stable_io_error(error.kind()) ), + path, + &output.diagnostics, + artifacts, + stdout, stderr, ); } - } - EXIT_SUCCESS -} - -#[cfg(test)] -fn load_provider_pack(root: &Path) -> Result { - load_provider_pack_with_expected_id(root, None) + artifacts.push(machine_output::Artifact::provider_notice(notice_path)); + } + write_machine_output( + machine_output::Envelope::result( + machine_output::Command::Render, + machine_output::Outcome::Success, + EXIT_SUCCESS, + path, + &output.diagnostics, + artifacts, + ), + stdout, + stderr, + ) } -fn load_provider_pack_for(root: &Path, expected_provider_id: &str) -> Result { - load_provider_pack_with_expected_id(root, Some(expected_provider_id)) +fn load_provider_store(root: &Path, allow_missing: bool) -> Result, String> { + let metadata = match fs::symlink_metadata(root) { + Ok(metadata) => metadata, + Err(error) if allow_missing && error.kind() == io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(error) => return Err(stable_io_error(error.kind()).to_owned()), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err("store path must be a real directory, not a symlink".to_owned()); + } + + let mut packs = Vec::new(); + for provider_id in provider_catalog::PROVIDER_IDS { + let pack_root = root.join(provider_id); + match fs::symlink_metadata(&pack_root) { + Ok(_) => packs.push(load_provider_pack_for(&pack_root, provider_id)?), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "cannot inspect '{}': {}", + pack_root.display(), + stable_io_error(error.kind()) + )); + } + } + } + Ok(packs) +} + +fn render_file_with( + path: &Path, + destination: RenderDestination, + notice_path: Option<&Path>, + stdout: &mut dyn Write, + stderr: &mut dyn Write, + render: impl FnOnce(&[u8]) -> Result, + mut write_output: impl FnMut(&Path, &[u8]) -> io::Result<()>, +) -> u8 { + let source = match fs::read(path) { + Ok(source) => source, + Err(error) => { + return write_stderr_error( + &format!( + "cannot read '{}': {}", + path.display(), + stable_io_error(error.kind()) + ), + stderr, + ); + } + }; + let output = match render(&source) { + Ok(output) => output, + Err(error) => { + return write_stderr_error( + &format!("cannot render '{}': {error}", path.display()), + stderr, + ); + } + }; + let has_errors = match write_diagnostics(path, &output.diagnostics, stderr) { + Ok(has_errors) => has_errors, + Err(()) => return EXIT_USAGE_OR_IO, + }; + if has_errors { + return EXIT_STACK_ERROR; + } + let Some(svg) = output.svg else { + return write_stderr_error("renderer produced no SVG or error diagnostic", stderr); + }; + + match destination { + RenderDestination::Stdout => { + if stdout.write_all(svg.as_bytes()).is_err() { + return write_stderr_error("cannot write rendered SVG", stderr); + } + } + RenderDestination::File(output_path) => { + if let Err(error) = write_output(&output_path, svg.as_bytes()) { + return write_stderr_error( + &format!( + "cannot write '{}': {}", + output_path.display(), + stable_io_error(error.kind()) + ), + stderr, + ); + } + } + } + if let Some(notice_path) = notice_path { + let notice = render_provider_notices(&output.provider_notices); + if let Err(error) = write_output(notice_path, notice.as_bytes()) { + return write_stderr_error( + &format!( + "cannot write '{}': {}", + notice_path.display(), + stable_io_error(error.kind()) + ), + stderr, + ); + } + } + EXIT_SUCCESS +} + +#[cfg(test)] +fn load_provider_pack(root: &Path) -> Result { + load_provider_pack_with_expected_id(root, None) +} + +fn load_provider_pack_for(root: &Path, expected_provider_id: &str) -> Result { + load_provider_pack_with_expected_id(root, Some(expected_provider_id)) } fn load_provider_pack_with_expected_id( @@ -2099,6 +2643,220 @@ fn format_stdin_with( } } +fn format_file_json( + mode: FormatMode, + path: &Path, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let source = match fs::read(path) { + Ok(source) => source, + Err(error) => { + return output_operational_error( + machine_output::Command::Fmt, + OutputMode::Json, + machine_output::IO_ERROR, + format!( + "cannot read '{}': {}", + path.display(), + stable_io_error(error.kind()) + ), + path, + &[], + Vec::new(), + stdout, + stderr, + ); + } + }; + let output = match Engine::bundled().format(&source) { + Ok(output) => output, + Err(error) => { + return output_operational_error( + machine_output::Command::Fmt, + OutputMode::Json, + machine_output::ENGINE_ERROR, + format!("cannot format '{}': {error}", path.display()), + path, + &[], + Vec::new(), + stdout, + stderr, + ); + } + }; + write_format_json_result( + mode, + path, + &source, + output, + Some(path), + |formatted| atomic_replace(path, formatted), + stdout, + stderr, + ) +} + +fn format_stdin_json( + mode: FormatMode, + stdin: &mut dyn Read, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let diagnostics_path = Path::new(""); + let mut source = Vec::new(); + if stdin.read_to_end(&mut source).is_err() { + return output_operational_error( + machine_output::Command::Fmt, + OutputMode::Json, + machine_output::IO_ERROR, + "cannot read standard input".to_owned(), + diagnostics_path, + &[], + Vec::new(), + stdout, + stderr, + ); + } + let output = match Engine::bundled().format(&source) { + Ok(output) => output, + Err(error) => { + return output_operational_error( + machine_output::Command::Fmt, + OutputMode::Json, + machine_output::ENGINE_ERROR, + format!("cannot format stdin: {error}"), + diagnostics_path, + &[], + Vec::new(), + stdout, + stderr, + ); + } + }; + write_format_json_result( + mode, + diagnostics_path, + &source, + output, + None, + |_| Ok(()), + stdout, + stderr, + ) +} + +#[allow(clippy::too_many_arguments)] +fn write_format_json_result( + mode: FormatMode, + diagnostics_path: &Path, + source: &[u8], + output: FormatOutput, + artifact_path: Option<&Path>, + replace: impl FnOnce(&[u8]) -> io::Result<()>, + stdout: &mut dyn Write, + stderr: &mut dyn Write, +) -> u8 { + let has_errors = output + .diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == Severity::Error); + let Some(formatted) = output.formatted_source else { + if has_errors { + return write_machine_output( + machine_output::Envelope::result( + machine_output::Command::Fmt, + machine_output::Outcome::StackError, + EXIT_STACK_ERROR, + diagnostics_path, + &output.diagnostics, + Vec::new(), + ), + stdout, + stderr, + ); + } + return output_operational_error( + machine_output::Command::Fmt, + OutputMode::Json, + machine_output::INTERNAL_ERROR, + "formatter produced no source or error diagnostic".to_owned(), + diagnostics_path, + &output.diagnostics, + Vec::new(), + stdout, + stderr, + ); + }; + let changed = formatted.as_bytes() != source; + + if mode == FormatMode::Check { + let (outcome, exit_status) = if has_errors { + (machine_output::Outcome::StackError, EXIT_STACK_ERROR) + } else if changed { + (machine_output::Outcome::ChangesRequired, EXIT_STACK_ERROR) + } else { + (machine_output::Outcome::Success, EXIT_SUCCESS) + }; + return write_machine_output( + machine_output::Envelope::result( + machine_output::Command::Fmt, + outcome, + exit_status, + diagnostics_path, + &output.diagnostics, + Vec::new(), + ), + stdout, + stderr, + ); + } + + if changed { + if let Err(error) = replace(formatted.as_bytes()) { + let target = artifact_path.unwrap_or(diagnostics_path); + return output_operational_error( + machine_output::Command::Fmt, + OutputMode::Json, + machine_output::IO_ERROR, + format!( + "cannot replace '{}': {}", + target.display(), + stable_io_error(error.kind()) + ), + diagnostics_path, + &output.diagnostics, + Vec::new(), + stdout, + stderr, + ); + } + } + + let content = artifact_path.is_none().then_some(formatted); + let artifacts = vec![machine_output::Artifact::formatted_source( + artifact_path, + content, + )]; + let (outcome, exit_status) = if has_errors { + (machine_output::Outcome::StackError, EXIT_STACK_ERROR) + } else { + (machine_output::Outcome::Success, EXIT_SUCCESS) + }; + write_machine_output( + machine_output::Envelope::result( + machine_output::Command::Fmt, + outcome, + exit_status, + diagnostics_path, + &output.diagnostics, + artifacts, + ), + stdout, + stderr, + ) +} + fn atomic_replace(path: &Path, contents: &[u8]) -> io::Result<()> { let permissions = fs::metadata(path)?.permissions(); atomic_write(path, contents, Some(permissions)) @@ -3337,4 +4095,317 @@ mod tests { assert!(stdout.is_empty()); assert!(fs::remove_file(path).is_ok()); } + + #[test] + fn machine_output_failures_and_artifact_edges_remain_structured() { + let path = std::env::temp_dir().join(format!( + "stack-cli-machine-output-{}.stack", + std::process::id() + )); + let missing = path.with_extension("missing"); + let notice_path = path.with_extension("NOTICE.md"); + let provider_root = path.with_extension("icons"); + let valid_source = b"stack 1.0 diagram \"Valid\" { node api \"API\" }"; + assert!(fs::write(&path, valid_source).is_ok()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + check_file_json_with(&path, &mut stdout, &mut stderr, |_| { + Err(OperationalError::InvalidIntermediateRepresentation { + reason: "test failure", + }) + }), + EXIT_USAGE_OR_IO + ); + assert!(stderr.is_empty()); + assert!(String::from_utf8_lossy(&stdout).contains("\"code\":\"CLI1004\"")); + + stdout.clear(); + assert_eq!( + format_file_json(FormatMode::Write, &missing, &mut stdout, &mut stderr), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"code\":\"CLI1002\"")); + + stdout.clear(); + assert_eq!( + format_stdin_json( + FormatMode::Write, + &mut FailingReader, + &mut stdout, + &mut stderr + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("cannot read standard input")); + + let valid_format = Engine::bundled().format(valid_source); + assert!(valid_format.is_ok()); + let Ok(mut empty_format) = valid_format else { + return; + }; + empty_format.formatted_source = None; + empty_format.diagnostics.clear(); + stdout.clear(); + assert_eq!( + write_format_json_result( + FormatMode::Write, + &path, + valid_source, + empty_format, + Some(&path), + |_| Ok(()), + &mut stdout, + &mut stderr, + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"code\":\"CLI1005\"")); + + let syntax = b"stack 1.0 diagram \"Incomplete\" {"; + let syntax_format = Engine::bundled().format(syntax); + assert!(syntax_format.is_ok()); + let Ok(syntax_format) = syntax_format else { + return; + }; + stdout.clear(); + assert_eq!( + write_format_json_result( + FormatMode::Write, + &path, + syntax, + syntax_format, + Some(&path), + |_| Ok(()), + &mut stdout, + &mut stderr, + ), + EXIT_STACK_ERROR + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"outcome\":\"stack-error\"")); + + let canonical = b"stack 1.0\n\ndiagram \"Valid\" {\n node api \"API\"\n}\n"; + let clean_format = Engine::bundled().format(canonical); + assert!(clean_format.is_ok()); + let Ok(clean_format) = clean_format else { + return; + }; + stdout.clear(); + assert_eq!( + write_format_json_result( + FormatMode::Check, + &path, + canonical, + clean_format, + Some(&path), + |_| Ok(()), + &mut stdout, + &mut stderr, + ), + EXIT_SUCCESS + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"outcome\":\"success\"")); + + let unformatted = b"stack 1.0 diagram \"Valid\"{node api \"API\"}"; + let replace_failure = Engine::bundled().format(unformatted); + assert!(replace_failure.is_ok()); + let Ok(replace_failure) = replace_failure else { + return; + }; + stdout.clear(); + assert_eq!( + write_format_json_result( + FormatMode::Write, + &path, + unformatted, + replace_failure, + Some(&path), + |_| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + &mut stdout, + &mut stderr, + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("permission denied")); + + let semantic = b"stack 1.0 diagram \"Invalid\" { node api \"A\" node api \"B\" }"; + let semantic_format = Engine::bundled().format(semantic); + assert!(semantic_format.is_ok()); + let Ok(semantic_format) = semantic_format else { + return; + }; + stdout.clear(); + assert_eq!( + write_format_json_result( + FormatMode::Write, + &path, + semantic, + semantic_format, + None, + |_| Ok(()), + &mut stdout, + &mut stderr, + ), + EXIT_STACK_ERROR + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"related\":[{")); + assert!(String::from_utf8_lossy(&stdout).contains("\"formatted-source\"")); + + stdout.clear(); + assert_eq!( + render_file_json_with( + &missing, + RenderDestination::Stdout, + None, + &mut stdout, + &mut stderr, + |source| Engine::bundled().render(source), + atomic_write_output, + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"code\":\"CLI1002\"")); + + stdout.clear(); + assert_eq!( + render_file_json_with( + &path, + RenderDestination::Stdout, + None, + &mut stdout, + &mut stderr, + |_| { + Err(OperationalError::InvalidIntermediateRepresentation { + reason: "test failure", + }) + }, + atomic_write_output, + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"code\":\"CLI1004\"")); + + let valid_render = Engine::bundled().render(valid_source); + assert!(valid_render.is_ok()); + let Ok(mut empty_render) = valid_render else { + return; + }; + empty_render.svg = None; + empty_render.diagnostics.clear(); + stdout.clear(); + assert_eq!( + render_file_json_with( + &path, + RenderDestination::Stdout, + None, + &mut stdout, + &mut stderr, + |_| Ok(empty_render), + atomic_write_output, + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"code\":\"CLI1005\"")); + + let syntax_render = Engine::bundled().render(syntax); + assert!(syntax_render.is_ok()); + let Ok(syntax_render) = syntax_render else { + return; + }; + stdout.clear(); + assert_eq!( + render_file_json_with( + &path, + RenderDestination::Stdout, + None, + &mut stdout, + &mut stderr, + |_| Ok(syntax_render), + atomic_write_output, + ), + EXIT_STACK_ERROR + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"outcome\":\"stack-error\"")); + + let output_path = path.with_extension("svg"); + stdout.clear(); + assert_eq!( + render_file_json_with( + &path, + RenderDestination::File(output_path), + None, + &mut stdout, + &mut stderr, + |source| Engine::bundled().render(source), + |_, _| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("permission denied")); + + stdout.clear(); + assert_eq!( + render_file_json_with( + &path, + RenderDestination::Stdout, + Some(¬ice_path), + &mut stdout, + &mut stderr, + |source| Engine::bundled().render(source), + |_, _| Ok(()), + ), + EXIT_SUCCESS + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"provider-notice\"")); + + stdout.clear(); + assert_eq!( + render_file_json_with( + &path, + RenderDestination::Stdout, + Some(¬ice_path), + &mut stdout, + &mut stderr, + |source| Engine::bundled().render(source), + |_, _| Err(io::Error::from(io::ErrorKind::PermissionDenied)), + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"artifacts\":[{")); + + assert!(fs::write(&provider_root, b"not a directory").is_ok()); + stdout.clear(); + assert_eq!( + render_file_json( + &path, + RenderDestination::Stdout, + &provider_root, + false, + None, + &mut stdout, + &mut stderr, + ), + EXIT_USAGE_OR_IO + ); + assert!(String::from_utf8_lossy(&stdout).contains("\"code\":\"CLI1003\"")); + + let envelope = machine_output::Envelope::result( + machine_output::Command::Check, + machine_output::Outcome::Success, + EXIT_SUCCESS, + &path, + &[], + Vec::new(), + ); + stderr.clear(); + assert_eq!( + write_machine_output(envelope, &mut FailingWriter, &mut stderr), + EXIT_USAGE_OR_IO + ); + assert_eq!(stderr, b"error: cannot write JSON output\n"); + + assert!(fs::remove_file(path).is_ok()); + assert!(fs::remove_file(provider_root).is_ok()); + } } diff --git a/src/machine_output.rs b/src/machine_output.rs new file mode 100644 index 0000000..18d2c8c --- /dev/null +++ b/src/machine_output.rs @@ -0,0 +1,246 @@ +//! Stable machine-readable output owned by the native CLI. + +use std::path::Path; + +use serde::Serialize; +use stack_engine::{Diagnostic, Severity, SourceRange}; + +pub(crate) const SCHEMA_URI: &str = + "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json"; +pub(crate) const SCHEMA_VERSION: u8 = 1; + +pub(crate) const ARGUMENT_ERROR: &str = "CLI1001"; +pub(crate) const IO_ERROR: &str = "CLI1002"; +pub(crate) const CONFIGURATION_ERROR: &str = "CLI1003"; +pub(crate) const ENGINE_ERROR: &str = "CLI1004"; +pub(crate) const INTERNAL_ERROR: &str = "CLI1005"; + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Command { + Check, + Fmt, + Render, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum Outcome { + Success, + ChangesRequired, + StackError, + OperationalError, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Envelope { + #[serde(rename = "$schema")] + schema: &'static str, + schema_version: u8, + command: Command, + outcome: Outcome, + exit_status: u8, + diagnostics: Vec, + artifacts: Vec, + error: Option, +} + +impl Envelope { + pub(crate) fn result( + command: Command, + outcome: Outcome, + exit_status: u8, + source_path: &Path, + diagnostics: &[Diagnostic], + artifacts: Vec, + ) -> Self { + Self { + schema: SCHEMA_URI, + schema_version: SCHEMA_VERSION, + command, + outcome, + exit_status, + diagnostics: diagnostics + .iter() + .map(|diagnostic| MachineDiagnostic::new(source_path, diagnostic)) + .collect(), + artifacts, + error: None, + } + } + + pub(crate) fn operational_error( + command: Command, + code: &'static str, + message: String, + diagnostics_path: &Path, + diagnostics: &[Diagnostic], + artifacts: Vec, + ) -> Self { + Self { + schema: SCHEMA_URI, + schema_version: SCHEMA_VERSION, + command, + outcome: Outcome::OperationalError, + exit_status: super::EXIT_USAGE_OR_IO, + diagnostics: diagnostics + .iter() + .map(|diagnostic| MachineDiagnostic::new(diagnostics_path, diagnostic)) + .collect(), + artifacts, + error: Some(MachineError { code, message }), + } + } + + pub(crate) const fn exit_status(&self) -> u8 { + self.exit_status + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MachineDiagnostic { + code: String, + severity: MachineSeverity, + message: String, + path: String, + range: MachineRange, + expected: Vec, + help: Option, + related: Vec, +} + +impl MachineDiagnostic { + fn new(path: &Path, diagnostic: &Diagnostic) -> Self { + let path = path.to_string_lossy().into_owned(); + Self { + code: diagnostic.code.clone(), + severity: diagnostic.severity.into(), + message: diagnostic.message.clone(), + path: path.clone(), + range: diagnostic.range.into(), + expected: diagnostic.expected.clone(), + help: diagnostic.help.clone(), + related: diagnostic + .related + .iter() + .map(|related| MachineRelatedInformation { + message: related.message.clone(), + path: path.clone(), + range: related.range.into(), + }) + .collect(), + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "lowercase")] +enum MachineSeverity { + Error, + Warning, +} + +impl From for MachineSeverity { + fn from(severity: Severity) -> Self { + match severity { + Severity::Error => Self::Error, + Severity::Warning => Self::Warning, + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MachineRange { + start: MachinePosition, + end: MachinePosition, +} + +impl From for MachineRange { + fn from(range: SourceRange) -> Self { + Self { + start: range.start.into(), + end: range.end.into(), + } + } +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MachinePosition { + byte_offset: u64, + line: u64, + column: u64, +} + +impl From for MachinePosition { + fn from(position: stack_engine::SourcePosition) -> Self { + Self { + byte_offset: position.byte_offset, + line: position.line, + column: position.column, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct MachineRelatedInformation { + message: String, + path: String, + range: MachineRange, +} + +#[derive(Debug, Serialize)] +pub(crate) struct Artifact { + kind: ArtifactKind, + path: Option, + #[serde(rename = "mediaType")] + media_type: &'static str, + content: Option, +} + +impl Artifact { + pub(crate) fn formatted_source(path: Option<&Path>, content: Option) -> Self { + Self { + kind: ArtifactKind::FormattedSource, + path: path.map(|path| path.to_string_lossy().into_owned()), + media_type: "text/vnd.stack", + content, + } + } + + pub(crate) fn rendered_svg(path: Option<&Path>, content: Option) -> Self { + Self { + kind: ArtifactKind::RenderedSvg, + path: path.map(|path| path.to_string_lossy().into_owned()), + media_type: "image/svg+xml", + content, + } + } + + pub(crate) fn provider_notice(path: &Path) -> Self { + Self { + kind: ArtifactKind::ProviderNotice, + path: Some(path.to_string_lossy().into_owned()), + media_type: "text/markdown", + content: None, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "kebab-case")] +enum ArtifactKind { + FormattedSource, + RenderedSvg, + ProviderNotice, +} + +#[derive(Debug, Serialize)] +struct MachineError { + code: &'static str, + message: String, +} diff --git a/tests/cli.rs b/tests/cli.rs index 2c7d9de..59ec092 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ use std::env; use std::error::Error; -use std::ffi::OsStr; +use std::ffi::{OsStr, OsString}; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; @@ -173,6 +173,23 @@ fn assert_stdout_only(arguments: &[&str], expected: &[u8]) -> Result<(), Box Result> { + assert!(output.stderr.is_empty()); + let value = serde_json::from_slice::(&output.stdout)?; + assert_eq!( + value["exitStatus"].as_i64(), + output.status.code().map(i64::from) + ); + Ok(value) +} + +fn json_fixture(name: &str) -> Result> { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/cli-output") + .join(name); + Ok(serde_json::from_slice(&fs::read(path)?)?) +} + #[test] fn valid_source_is_silent_and_unchanged() -> Result<(), Box> { let directory = TestDirectory::new("valid")?; @@ -243,6 +260,353 @@ fn missing_file_exits_two_with_a_stable_host_error() -> Result<(), Box Result<(), Box> { + let directory = TestDirectory::new("json-check")?; + let valid_path = directory.file( + "valid.stack", + b"stack 1.0 diagram \"Valid\" { node api \"API\" }", + )?; + let valid = stack([ + OsStr::new("check"), + OsStr::new("--json"), + valid_path.as_os_str(), + ])?; + assert_eq!( + parse_json_output(&valid)?, + json_fixture("check-success.json")? + ); + + let warning_path = directory.file("warning.stack", include_bytes!("fixtures/warning.stack"))?; + let warning = stack([ + OsStr::new("check"), + warning_path.as_os_str(), + OsStr::new("--json"), + ])?; + let mut warning_json = parse_json_output(&warning)?; + warning_json["diagnostics"][0]["path"] = json!(""); + assert_eq!(warning_json, json_fixture("check-warning.json")?); + + let invalid_path = directory.file("invalid.stack", include_bytes!("fixtures/invalid.stack"))?; + let invalid = stack([ + OsStr::new("check"), + invalid_path.as_os_str(), + OsStr::new("--json"), + ])?; + let mut invalid_json = parse_json_output(&invalid)?; + invalid_json["diagnostics"][0]["path"] = json!(""); + assert_eq!(invalid_json, json_fixture("check-stack-error.json")?); + + let missing_path = directory.path.join("missing.stack"); + let missing = stack([ + OsStr::new("check"), + missing_path.as_os_str(), + OsStr::new("--json"), + ])?; + let mut missing_json = parse_json_output(&missing)?; + missing_json["error"]["message"] = json!("cannot read '': file not found"); + assert_eq!(missing_json, json_fixture("operational-error.json")?); + Ok(()) +} + +#[test] +fn json_format_results_preserve_check_write_and_stdin_semantics() -> Result<(), Box> { + let directory = TestDirectory::new("json-format")?; + let source = b"stack 1.0 diagram \"Check\"{node api \"API\"}"; + let expected = b"stack 1.0\n\ndiagram \"Check\" {\n node api \"API\"\n}\n"; + let path = directory.file("format.stack", source)?; + + let checked = stack([ + OsStr::new("fmt"), + OsStr::new("--check"), + path.as_os_str(), + OsStr::new("--json"), + ])?; + assert_eq!( + parse_json_output(&checked)?, + json_fixture("fmt-changes-required.json")? + ); + assert_unchanged(&path, source)?; + + let written = stack([OsStr::new("fmt"), OsStr::new("--json"), path.as_os_str()])?; + let written_json = parse_json_output(&written)?; + assert_eq!(written_json["outcome"], "success"); + assert_eq!(written_json["artifacts"][0]["kind"], "formatted-source"); + assert_eq!( + written_json["artifacts"][0]["path"], + path.to_string_lossy().as_ref() + ); + assert!(written_json["artifacts"][0]["content"].is_null()); + assert_eq!(fs::read(&path)?, expected); + + let stdin = b"stack 1.0 diagram \"Stdin\"{node api \"API\"}"; + let formatted = stack_with_input(["fmt", "--json", "-"], stdin)?; + assert_eq!( + parse_json_output(&formatted)?, + json_fixture("fmt-stdin-success.json")? + ); + Ok(()) +} + +#[test] +fn json_render_reports_inline_and_written_artifacts() -> Result<(), Box> { + let directory = TestDirectory::new("json-render")?; + let source = b"stack 1.0 diagram \"Render\" { node api \"API\" }"; + let input = directory.file("render.stack", source)?; + let artifact = directory.path.join("render.svg"); + + let written = stack([ + OsStr::new("render"), + input.as_os_str(), + OsStr::new("-o"), + artifact.as_os_str(), + OsStr::new("--json"), + ])?; + let mut written_json = parse_json_output(&written)?; + written_json["artifacts"][0]["path"] = json!(""); + assert_eq!(written_json, json_fixture("render-file-success.json")?); + assert!(fs::read_to_string(&artifact)?.contains(" Result<(), Box> { + for arguments in [ + vec!["check", "--json"], + vec!["fmt", "--json", "--json", "-"], + vec!["render", "--json", "--unknown"], + ] { + let output = stack(arguments)?; + let value = parse_json_output(&output)?; + assert_eq!(value["outcome"], "operational-error"); + assert_eq!(value["error"]["code"], "CLI1001"); + } + Ok(()) +} + +#[test] +fn invalid_argument_matrix_covers_each_command_boundary() -> Result<(), Box> { + let directory = TestDirectory::new("argument-matrix")?; + let source = directory.file( + "source.stack", + b"stack 1.0 diagram \"Valid\" { node api \"API\" }", + )?; + let source = source.into_os_string(); + let cases = vec![ + vec!["help", "--help", "extra"], + vec!["help", "check", "extra"], + vec!["version", "--help", "extra"], + vec!["version", "extra"], + vec!["completions"], + vec!["completions", "--help", "extra"], + vec!["completions", "bash", "extra"], + vec!["manpage", "--help", "extra"], + vec!["manpage", "extra"], + vec!["lsp", "--help", "extra"], + vec!["lsp", "extra"], + vec!["config"], + vec!["config", "--help", "extra"], + vec!["config", "help", "path", "extra"], + vec!["config", "path", "--help", "extra"], + vec!["config", "path", "extra"], + vec!["config", "get"], + vec!["config", "get", "--help", "extra"], + vec!["config", "get", "default_icons_path", "extra"], + vec!["config", "get", "unknown"], + vec!["doctor", "--help", "extra"], + vec!["doctor", "--provider-pack"], + vec![ + "doctor", + "--provider-pack", + "first", + "--provider-pack", + "second", + ], + vec!["doctor", "--unknown"], + vec!["icons"], + vec!["icons", "--help", "extra"], + vec!["icons", "help", "list", "extra"], + vec!["icons", "list", "--help", "extra"], + vec!["icons", "list", "aws", "s3", "extra"], + vec!["icons", "import"], + vec!["icons", "import", "--help", "extra"], + vec!["icons", "import", "--unknown"], + vec!["icons", "import", "aws", "--accept-terms", "--accept-terms"], + vec!["icons", "import", "aws", "-o"], + vec!["icons", "import", "aws", "unexpected"], + vec!["icons", "import", "aws"], + ]; + for arguments in cases { + let output = stack(arguments.iter().copied())?; + assert_eq!(output.status.code(), Some(2), "arguments: {arguments:?}"); + assert!(output.stdout.is_empty(), "arguments: {arguments:?}"); + assert!(!output.stderr.is_empty(), "arguments: {arguments:?}"); + } + + let machine_cases = vec![ + vec![ + OsString::from("check"), + OsString::from("--json"), + OsString::from("--json"), + source.clone(), + ], + vec![ + OsString::from("check"), + source.clone(), + OsString::from("extra"), + OsString::from("--json"), + ], + vec![ + OsString::from("check"), + OsString::from("--help"), + OsString::from("extra"), + OsString::from("--json"), + ], + vec![ + OsString::from("fmt"), + OsString::from("--help"), + OsString::from("extra"), + OsString::from("--json"), + ], + vec![ + OsString::from("fmt"), + OsString::from("--check"), + OsString::from("--json"), + ], + vec![ + OsString::from("fmt"), + OsString::from("--unknown"), + OsString::from("--json"), + ], + vec![ + OsString::from("fmt"), + source.clone(), + OsString::from("extra"), + OsString::from("--json"), + ], + vec![OsString::from("render"), OsString::from("--json")], + vec![ + OsString::from("render"), + OsString::from("--json"), + OsString::from("--json"), + source.clone(), + ], + vec![ + OsString::from("render"), + OsString::from("--help"), + OsString::from("extra"), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("-o"), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("-o"), + OsString::from("first.svg"), + OsString::from("-o"), + OsString::from("second.svg"), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("--notice"), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("--notice"), + OsString::from("first.md"), + OsString::from("--notice"), + OsString::from("second.md"), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("--provider-pack"), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("--provider-pack"), + OsString::from("first"), + OsString::from("--provider-pack"), + OsString::from("second"), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("extra"), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("-o"), + source.clone(), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("--notice"), + source.clone(), + OsString::from("--json"), + ], + vec![ + OsString::from("render"), + source.clone(), + OsString::from("-o"), + OsString::from("same"), + OsString::from("--notice"), + OsString::from("same"), + OsString::from("--json"), + ], + ]; + for arguments in machine_cases { + let output = stack(arguments.iter())?; + let value = parse_json_output(&output)?; + assert_eq!(value["outcome"], "operational-error"); + assert_eq!(value["error"]["code"], "CLI1001"); + } + + let no_config = stack_with_config_environment( + None, + None, + [ + OsStr::new("render"), + source.as_os_str(), + OsStr::new("--json"), + ], + )?; + let no_config = parse_json_output(&no_config)?; + assert_eq!(no_config["error"]["code"], "CLI1003"); + Ok(()) +} + #[test] fn help_snapshots_and_aliases_are_stdout_only() -> Result<(), Box> { let cases: &[(&[&str], &[u8])] = &[ diff --git a/tests/fixtures/cli-output/check-stack-error.json b/tests/fixtures/cli-output/check-stack-error.json new file mode 100644 index 0000000..17ef669 --- /dev/null +++ b/tests/fixtures/cli-output/check-stack-error.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "schemaVersion": 1, + "command": "check", + "outcome": "stack-error", + "exitStatus": 1, + "diagnostics": [ + { + "code": "STK2003", + "severity": "error", + "message": "Input ended before the diagram was complete.", + "path": "", + "range": { + "start": { "byteOffset": 33, "line": 2, "column": 1 }, + "end": { "byteOffset": 33, "line": 2, "column": 1 } + }, + "expected": ["}"], + "help": "Add the closing '}' for this construct.", + "related": [] + } + ], + "artifacts": [], + "error": null +} diff --git a/tests/fixtures/cli-output/check-success.json b/tests/fixtures/cli-output/check-success.json new file mode 100644 index 0000000..9033143 --- /dev/null +++ b/tests/fixtures/cli-output/check-success.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "schemaVersion": 1, + "command": "check", + "outcome": "success", + "exitStatus": 0, + "diagnostics": [], + "artifacts": [], + "error": null +} diff --git a/tests/fixtures/cli-output/check-warning.json b/tests/fixtures/cli-output/check-warning.json new file mode 100644 index 0000000..631227c --- /dev/null +++ b/tests/fixtures/cli-output/check-warning.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "schemaVersion": 1, + "command": "check", + "outcome": "success", + "exitStatus": 0, + "diagnostics": [ + { + "code": "STK6001", + "severity": "warning", + "message": "theme 'neon' is unavailable; default theme was used", + "path": "", + "range": { + "start": { "byteOffset": 37, "line": 1, "column": 38 }, + "end": { "byteOffset": 41, "line": 1, "column": 42 } + }, + "expected": [], + "help": "Install the requested theme or select an available theme.", + "related": [] + } + ], + "artifacts": [], + "error": null +} diff --git a/tests/fixtures/cli-output/fmt-changes-required.json b/tests/fixtures/cli-output/fmt-changes-required.json new file mode 100644 index 0000000..74e6bda --- /dev/null +++ b/tests/fixtures/cli-output/fmt-changes-required.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "schemaVersion": 1, + "command": "fmt", + "outcome": "changes-required", + "exitStatus": 1, + "diagnostics": [], + "artifacts": [], + "error": null +} diff --git a/tests/fixtures/cli-output/fmt-stdin-success.json b/tests/fixtures/cli-output/fmt-stdin-success.json new file mode 100644 index 0000000..ec005d5 --- /dev/null +++ b/tests/fixtures/cli-output/fmt-stdin-success.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "schemaVersion": 1, + "command": "fmt", + "outcome": "success", + "exitStatus": 0, + "diagnostics": [], + "artifacts": [ + { + "kind": "formatted-source", + "path": null, + "mediaType": "text/vnd.stack", + "content": "stack 1.0\n\ndiagram \"Stdin\" {\n node api \"API\"\n}\n" + } + ], + "error": null +} diff --git a/tests/fixtures/cli-output/operational-error.json b/tests/fixtures/cli-output/operational-error.json new file mode 100644 index 0000000..4cf613c --- /dev/null +++ b/tests/fixtures/cli-output/operational-error.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "schemaVersion": 1, + "command": "check", + "outcome": "operational-error", + "exitStatus": 2, + "diagnostics": [], + "artifacts": [], + "error": { + "code": "CLI1002", + "message": "cannot read '': file not found" + } +} diff --git a/tests/fixtures/cli-output/render-file-success.json b/tests/fixtures/cli-output/render-file-success.json new file mode 100644 index 0000000..d11df30 --- /dev/null +++ b/tests/fixtures/cli-output/render-file-success.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://raw.githubusercontent.com/stack-sh/cli/main/schemas/cli-output-v1.schema.json", + "schemaVersion": 1, + "command": "render", + "outcome": "success", + "exitStatus": 0, + "diagnostics": [], + "artifacts": [ + { + "kind": "rendered-svg", + "path": "", + "mediaType": "image/svg+xml", + "content": null + } + ], + "error": null +} diff --git a/tests/fixtures/invalid.stack b/tests/fixtures/invalid.stack new file mode 100644 index 0000000..23512bb --- /dev/null +++ b/tests/fixtures/invalid.stack @@ -0,0 +1 @@ +stack 1.0 diagram "Incomplete" { diff --git a/tests/fixtures/warning.stack b/tests/fixtures/warning.stack new file mode 100644 index 0000000..5a95b18 --- /dev/null +++ b/tests/fixtures/warning.stack @@ -0,0 +1 @@ +stack 1.0 diagram "Fallback" { theme neon node api "API" } diff --git a/tests/snapshots/check-help.txt b/tests/snapshots/check-help.txt index 69fd7e2..67fbb77 100644 --- a/tests/snapshots/check-help.txt +++ b/tests/snapshots/check-help.txt @@ -1,13 +1,15 @@ Validate a Stack source file without modifying it Usage: - stack check + stack check [--json] Arguments: Read Stack source bytes from this file Options: + --json Write one versioned JSON envelope to standard output -h, --help Print help Examples: stack check arch.stack + stack check arch.stack --json diff --git a/tests/snapshots/fmt-help.txt b/tests/snapshots/fmt-help.txt index d555488..c56f71c 100644 --- a/tests/snapshots/fmt-help.txt +++ b/tests/snapshots/fmt-help.txt @@ -1,9 +1,9 @@ Format Stack source canonically Usage: - stack fmt - stack fmt --check - stack fmt - + stack fmt [--json] + stack fmt --check [--json] + stack fmt - [--json] Arguments: Format the file atomically in place @@ -11,9 +11,11 @@ Arguments: Options: --check Report whether formatting is required without writing output + --json Write one versioned JSON envelope to standard output -h, --help Print help Examples: stack fmt arch.stack stack fmt --check arch.stack stack fmt - < input.stack > output.stack + stack fmt --json - < input.stack diff --git a/tests/snapshots/render-help.txt b/tests/snapshots/render-help.txt index a0f71f7..d2c417a 100644 --- a/tests/snapshots/render-help.txt +++ b/tests/snapshots/render-help.txt @@ -1,7 +1,7 @@ Render Stack source as standalone SVG Usage: - stack render [--provider-pack ] [-o ] [--notice ] + stack render [--provider-pack ] [-o ] [--notice ] [--json] Arguments: Read Stack source bytes from this file @@ -10,6 +10,7 @@ Options: --provider-pack Read known provider packs from this icon-store root -o Write SVG atomically instead of using standard output --notice Write exact used-provider notices atomically + --json Write one versioned JSON envelope to standard output -h, --help Print help Default icon store: @@ -19,3 +20,4 @@ Examples: stack render arch.stack stack render arch.stack -o arch.svg stack render arch.stack --notice arch.NOTICE.md -o arch.svg + stack render arch.stack --json From 461b6d29b953a1fa57df169bab38c9660f3f0232 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 19:35:47 +0900 Subject: [PATCH 2/3] Simplify machine output routing --- src/lib.rs | 599 ++++++++++++++++++++--------------------------------- 1 file changed, 221 insertions(+), 378 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3ebb24a..2044b53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -428,6 +428,71 @@ struct OutputArguments { duplicate_json: bool, } +struct CommandOutput<'a> { + command: machine_output::Command, + mode: OutputMode, + stdout: &'a mut dyn Write, + stderr: &'a mut dyn Write, +} + +impl<'a> CommandOutput<'a> { + fn new( + command: machine_output::Command, + mode: OutputMode, + stdout: &'a mut dyn Write, + stderr: &'a mut dyn Write, + ) -> Self { + Self { + command, + mode, + stdout, + stderr, + } + } + + fn argument_error(&mut self, message: &str) -> u8 { + match self.mode { + OutputMode::Human => argument_error(message, self.stderr), + OutputMode::Json => self.operational_error( + machine_output::ARGUMENT_ERROR, + message.to_owned(), + Path::new(""), + &[], + Vec::new(), + ), + } + } + + fn operational_error( + &mut self, + code: &'static str, + message: String, + diagnostics_path: &Path, + diagnostics: &[Diagnostic], + artifacts: Vec, + ) -> u8 { + match self.mode { + OutputMode::Human => write_stderr_error(&message, self.stderr), + OutputMode::Json => self.machine(machine_output::Envelope::operational_error( + self.command, + code, + message, + diagnostics_path, + diagnostics, + artifacts, + )), + } + } + + fn machine(&mut self, envelope: machine_output::Envelope) -> u8 { + write_machine_output(envelope, self.stdout, self.stderr) + } + + fn into_parts(self) -> (OutputMode, &'a mut dyn Write, &'a mut dyn Write) { + (self.mode, self.stdout, self.stderr) + } +} + fn output_arguments(arguments: impl Iterator) -> OutputArguments { let mut mode = OutputMode::Human; let mut values = Vec::new(); @@ -449,58 +514,6 @@ fn output_arguments(arguments: impl Iterator) -> OutputArgument } } -fn output_argument_error( - command: machine_output::Command, - mode: OutputMode, - message: &str, - stdout: &mut dyn Write, - stderr: &mut dyn Write, -) -> u8 { - match mode { - OutputMode::Human => argument_error(message, stderr), - OutputMode::Json => output_operational_error( - command, - mode, - machine_output::ARGUMENT_ERROR, - message.to_owned(), - Path::new(""), - &[], - Vec::new(), - stdout, - stderr, - ), - } -} - -#[allow(clippy::too_many_arguments)] -fn output_operational_error( - command: machine_output::Command, - mode: OutputMode, - code: &'static str, - message: String, - diagnostics_path: &Path, - diagnostics: &[Diagnostic], - artifacts: Vec, - stdout: &mut dyn Write, - stderr: &mut dyn Write, -) -> u8 { - match mode { - OutputMode::Human => write_stderr_error(&message, stderr), - OutputMode::Json => write_machine_output( - machine_output::Envelope::operational_error( - command, - code, - message, - diagnostics_path, - diagnostics, - artifacts, - ), - stdout, - stderr, - ), - } -} - fn write_machine_output( envelope: machine_output::Envelope, stdout: &mut dyn Write, @@ -1571,46 +1584,26 @@ fn run_render( stderr: &mut dyn Write, ) -> u8 { let parsed = output_arguments(arguments); - let mode = parsed.mode; + let mut output = + CommandOutput::new(machine_output::Command::Render, parsed.mode, stdout, stderr); if parsed.duplicate_json { - return output_argument_error( - machine_output::Command::Render, - mode, - "duplicate '--json' option", - stdout, - stderr, - ); + return output.argument_error("duplicate '--json' option"); } let mut arguments = parsed.values.into_iter(); let Some(source) = arguments.next() else { - return output_argument_error( - machine_output::Command::Render, - mode, - "missing file for 'stack render'", - stdout, - stderr, - ); + return output.argument_error("missing file for 'stack render'"); }; if source == OsStr::new("--help") || source == OsStr::new("-h") { if let Some(extra) = arguments.next() { - return output_argument_error( - machine_output::Command::Render, - mode, - &format!("unexpected argument '{}'", extra.to_string_lossy()), - stdout, - stderr, - ); + return output.argument_error(&format!( + "unexpected argument '{}'", + extra.to_string_lossy() + )); } - return write_stdout(RENDER_HELP, stdout, stderr); + return write_stdout(RENDER_HELP, output.stdout, output.stderr); } if source.to_string_lossy().starts_with('-') { - return output_argument_error( - machine_output::Command::Render, - mode, - &format!("unknown option '{}'", source.to_string_lossy()), - stdout, - stderr, - ); + return output.argument_error(&format!("unknown option '{}'", source.to_string_lossy())); } let mut destination = None; @@ -1619,105 +1612,48 @@ fn run_render( while let Some(option) = arguments.next() { if option == OsStr::new("-o") { if destination.is_some() { - return output_argument_error( - machine_output::Command::Render, - mode, - "duplicate '-o' option", - stdout, - stderr, - ); + return output.argument_error("duplicate '-o' option"); } let Some(output) = arguments.next() else { - return output_argument_error( - machine_output::Command::Render, - mode, - "missing output file after '-o'", - stdout, - stderr, - ); + return output.argument_error("missing output file after '-o'"); }; destination = Some(RenderDestination::File(PathBuf::from(output))); } else if option == OsStr::new("--notice") { if notice_path.is_some() { - return output_argument_error( - machine_output::Command::Render, - mode, - "duplicate '--notice' option", - stdout, - stderr, - ); + return output.argument_error("duplicate '--notice' option"); } let Some(path) = arguments.next() else { - return output_argument_error( - machine_output::Command::Render, - mode, - "missing notice file after '--notice'", - stdout, - stderr, - ); + return output.argument_error("missing notice file after '--notice'"); }; notice_path = Some(PathBuf::from(path)); } else if option == OsStr::new("--provider-pack") { if provider_pack_root.is_some() { - return output_argument_error( - machine_output::Command::Render, - mode, - "duplicate '--provider-pack' option", - stdout, - stderr, - ); + return output.argument_error("duplicate '--provider-pack' option"); } let Some(path) = arguments.next() else { - return output_argument_error( - machine_output::Command::Render, - mode, - "missing provider icon-store directory", - stdout, - stderr, - ); + return output.argument_error("missing provider icon-store directory"); }; provider_pack_root = Some(PathBuf::from(path)); } else { - return output_argument_error( - machine_output::Command::Render, - mode, - &format!("unexpected argument '{}'", option.to_string_lossy()), - stdout, - stderr, - ); + return output.argument_error(&format!( + "unexpected argument '{}'", + option.to_string_lossy() + )); } } let destination = destination.unwrap_or(RenderDestination::Stdout); if matches!(&destination, RenderDestination::File(path) if path.as_os_str() == source) { - return output_argument_error( - machine_output::Command::Render, - mode, - "input and output files must be different", - stdout, - stderr, - ); + return output.argument_error("input and output files must be different"); } if notice_path .as_ref() .is_some_and(|path| path.as_os_str() == source) { - return output_argument_error( - machine_output::Command::Render, - mode, - "input and notice files must be different", - stdout, - stderr, - ); + return output.argument_error("input and notice files must be different"); } if matches!(&destination, RenderDestination::File(path) if notice_path.as_ref() == Some(path)) { - return output_argument_error( - machine_output::Command::Render, - mode, - "output and notice files must be different", - stdout, - stderr, - ); + return output.argument_error("output and notice files must be different"); } let explicit_provider_pack_root = provider_pack_root.is_some(); @@ -1726,20 +1662,17 @@ fn run_render( match config::icon_store_root(provider_pack_root.as_deref(), &environment) { Ok(path) => path, Err(error) => { - return output_operational_error( - machine_output::Command::Render, - mode, + return output.operational_error( machine_output::CONFIGURATION_ERROR, error, Path::new(&source), &[], Vec::new(), - stdout, - stderr, ); } }; + let (mode, stdout, stderr) = output.into_parts(); match mode { OutputMode::Human => render_file( Path::new(&source), @@ -1769,72 +1702,43 @@ fn run_format( stderr: &mut dyn Write, ) -> u8 { let parsed = output_arguments(arguments); - let output_mode = parsed.mode; + let mut output = CommandOutput::new(machine_output::Command::Fmt, parsed.mode, stdout, stderr); if parsed.duplicate_json { - return output_argument_error( - machine_output::Command::Fmt, - output_mode, - "duplicate '--json' option", - stdout, - stderr, - ); + return output.argument_error("duplicate '--json' option"); } let mut arguments = parsed.values.into_iter(); let Some(first) = arguments.next() else { - return output_argument_error( - machine_output::Command::Fmt, - output_mode, - "missing file for 'stack fmt'", - stdout, - stderr, - ); + return output.argument_error("missing file for 'stack fmt'"); }; if first == OsStr::new("--help") || first == OsStr::new("-h") { if let Some(extra) = arguments.next() { - return output_argument_error( - machine_output::Command::Fmt, - output_mode, - &format!("unexpected argument '{}'", extra.to_string_lossy()), - stdout, - stderr, - ); + return output.argument_error(&format!( + "unexpected argument '{}'", + extra.to_string_lossy() + )); } - return write_stdout(FORMAT_HELP, stdout, stderr); + return write_stdout(FORMAT_HELP, output.stdout, output.stderr); } let (mode, input) = if first == OsStr::new("--check") { let Some(input) = arguments.next() else { - return output_argument_error( - machine_output::Command::Fmt, - output_mode, - "missing file for 'stack fmt --check'", - stdout, - stderr, - ); + return output.argument_error("missing file for 'stack fmt --check'"); }; (FormatMode::Check, input) } else { (FormatMode::Write, first) }; if input != OsStr::new("-") && input.to_string_lossy().starts_with('-') { - return output_argument_error( - machine_output::Command::Fmt, - output_mode, - &format!("unknown option '{}'", input.to_string_lossy()), - stdout, - stderr, - ); + return output.argument_error(&format!("unknown option '{}'", input.to_string_lossy())); } if let Some(extra) = arguments.next() { - return output_argument_error( - machine_output::Command::Fmt, - output_mode, - &format!("unexpected argument '{}'", extra.to_string_lossy()), - stdout, - stderr, - ); + return output.argument_error(&format!( + "unexpected argument '{}'", + extra.to_string_lossy() + )); } + let (output_mode, stdout, stderr) = output.into_parts(); match (output_mode, input == OsStr::new("-")) { (OutputMode::Human, true) => format_stdin(mode, stdin, stdout, stderr), (OutputMode::Human, false) => format_file(mode, Path::new(&input), stderr), @@ -1849,50 +1753,34 @@ fn run_check( stderr: &mut dyn Write, ) -> u8 { let parsed = output_arguments(arguments); - let mode = parsed.mode; + let mut output = + CommandOutput::new(machine_output::Command::Check, parsed.mode, stdout, stderr); if parsed.duplicate_json { - return output_argument_error( - machine_output::Command::Check, - mode, - "duplicate '--json' option", - stdout, - stderr, - ); + return output.argument_error("duplicate '--json' option"); } let mut arguments = parsed.values.into_iter(); let Some(path) = arguments.next() else { - return output_argument_error( - machine_output::Command::Check, - mode, - "missing file for 'stack check'", - stdout, - stderr, - ); + return output.argument_error("missing file for 'stack check'"); }; if path == OsStr::new("--help") || path == OsStr::new("-h") { if let Some(extra) = arguments.next() { - return output_argument_error( - machine_output::Command::Check, - mode, - &format!("unexpected argument '{}'", extra.to_string_lossy()), - stdout, - stderr, - ); + return output.argument_error(&format!( + "unexpected argument '{}'", + extra.to_string_lossy() + )); } - return write_stdout(CHECK_HELP, stdout, stderr); + return write_stdout(CHECK_HELP, output.stdout, output.stderr); } if let Some(extra) = arguments.next() { - return output_argument_error( - machine_output::Command::Check, - mode, - &format!("unexpected argument '{}'", extra.to_string_lossy()), - stdout, - stderr, - ); + return output.argument_error(&format!( + "unexpected argument '{}'", + extra.to_string_lossy() + )); } + let (mode, stdout, stderr) = output.into_parts(); match mode { OutputMode::Human => check_file(Path::new(&path), stderr), OutputMode::Json => check_file_json(Path::new(&path), stdout, stderr), @@ -1955,12 +1843,16 @@ fn check_file_json_with( stderr: &mut dyn Write, check: impl FnOnce(&[u8]) -> Result, ) -> u8 { + let mut result = CommandOutput::new( + machine_output::Command::Check, + OutputMode::Json, + stdout, + stderr, + ); let source = match fs::read(path) { Ok(source) => source, Err(error) => { - return output_operational_error( - machine_output::Command::Check, - OutputMode::Json, + return result.operational_error( machine_output::IO_ERROR, format!( "cannot read '{}': {}", @@ -1970,24 +1862,18 @@ fn check_file_json_with( path, &[], Vec::new(), - stdout, - stderr, ); } }; let output = match check(&source) { Ok(output) => output, Err(error) => { - return output_operational_error( - machine_output::Command::Check, - OutputMode::Json, + return result.operational_error( machine_output::ENGINE_ERROR, format!("cannot check '{}': {error}", path.display()), path, &[], Vec::new(), - stdout, - stderr, ); } }; @@ -2000,18 +1886,14 @@ fn check_file_json_with( } else { (machine_output::Outcome::Success, EXIT_SUCCESS) }; - write_machine_output( - machine_output::Envelope::result( - machine_output::Command::Check, - outcome, - exit_status, - path, - &output.diagnostics, - Vec::new(), - ), - stdout, - stderr, - ) + result.machine(machine_output::Envelope::result( + machine_output::Command::Check, + outcome, + exit_status, + path, + &output.diagnostics, + Vec::new(), + )) } fn render_file( @@ -2062,13 +1944,17 @@ fn render_file_json( stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> u8 { + let mut result = CommandOutput::new( + machine_output::Command::Render, + OutputMode::Json, + stdout, + stderr, + ); let provider_packs = match load_provider_store(provider_pack_root, allow_missing_provider_pack_root) { Ok(provider_packs) => provider_packs, Err(reason) => { - return output_operational_error( - machine_output::Command::Render, - OutputMode::Json, + return result.operational_error( machine_output::CONFIGURATION_ERROR, format!( "cannot load provider icon store '{}': {reason}", @@ -2077,27 +1963,22 @@ fn render_file_json( path, &[], Vec::new(), - stdout, - stderr, ); } }; let engine = match Engine::with_provider_packs(&provider_packs) { Ok(engine) => engine, Err(error) => { - return output_operational_error( - machine_output::Command::Render, - OutputMode::Json, + return result.operational_error( machine_output::ENGINE_ERROR, format!("cannot load provider packs: {error}"), path, &[], Vec::new(), - stdout, - stderr, ); } }; + let (_, stdout, stderr) = result.into_parts(); render_file_json_with( path, destination, @@ -2119,12 +2000,16 @@ fn render_file_json_with( render: impl FnOnce(&[u8]) -> Result, mut write_output: impl FnMut(&Path, &[u8]) -> io::Result<()>, ) -> u8 { + let mut result = CommandOutput::new( + machine_output::Command::Render, + OutputMode::Json, + stdout, + stderr, + ); let source = match fs::read(path) { Ok(source) => source, Err(error) => { - return output_operational_error( - machine_output::Command::Render, - OutputMode::Json, + return result.operational_error( machine_output::IO_ERROR, format!( "cannot read '{}': {}", @@ -2134,24 +2019,18 @@ fn render_file_json_with( path, &[], Vec::new(), - stdout, - stderr, ); } }; let output = match render(&source) { Ok(output) => output, Err(error) => { - return output_operational_error( - machine_output::Command::Render, - OutputMode::Json, + return result.operational_error( machine_output::ENGINE_ERROR, format!("cannot render '{}': {error}", path.display()), path, &[], Vec::new(), - stdout, - stderr, ); } }; @@ -2160,30 +2039,22 @@ fn render_file_json_with( .iter() .any(|diagnostic| diagnostic.severity == Severity::Error); if has_errors { - return write_machine_output( - machine_output::Envelope::result( - machine_output::Command::Render, - machine_output::Outcome::StackError, - EXIT_STACK_ERROR, - path, - &output.diagnostics, - Vec::new(), - ), - stdout, - stderr, - ); + return result.machine(machine_output::Envelope::result( + machine_output::Command::Render, + machine_output::Outcome::StackError, + EXIT_STACK_ERROR, + path, + &output.diagnostics, + Vec::new(), + )); } let Some(svg) = output.svg else { - return output_operational_error( - machine_output::Command::Render, - OutputMode::Json, + return result.operational_error( machine_output::INTERNAL_ERROR, "renderer produced no SVG or error diagnostic".to_owned(), path, &output.diagnostics, Vec::new(), - stdout, - stderr, ); }; @@ -2194,9 +2065,7 @@ fn render_file_json_with( } RenderDestination::File(output_path) => { if let Err(error) = write_output(&output_path, svg.as_bytes()) { - return output_operational_error( - machine_output::Command::Render, - OutputMode::Json, + return result.operational_error( machine_output::IO_ERROR, format!( "cannot write '{}': {}", @@ -2206,8 +2075,6 @@ fn render_file_json_with( path, &output.diagnostics, artifacts, - stdout, - stderr, ); } artifacts.push(machine_output::Artifact::rendered_svg( @@ -2219,9 +2086,7 @@ fn render_file_json_with( if let Some(notice_path) = notice_path { let notice = render_provider_notices(&output.provider_notices); if let Err(error) = write_output(notice_path, notice.as_bytes()) { - return output_operational_error( - machine_output::Command::Render, - OutputMode::Json, + return result.operational_error( machine_output::IO_ERROR, format!( "cannot write '{}': {}", @@ -2231,24 +2096,18 @@ fn render_file_json_with( path, &output.diagnostics, artifacts, - stdout, - stderr, ); } artifacts.push(machine_output::Artifact::provider_notice(notice_path)); } - write_machine_output( - machine_output::Envelope::result( - machine_output::Command::Render, - machine_output::Outcome::Success, - EXIT_SUCCESS, - path, - &output.diagnostics, - artifacts, - ), - stdout, - stderr, - ) + result.machine(machine_output::Envelope::result( + machine_output::Command::Render, + machine_output::Outcome::Success, + EXIT_SUCCESS, + path, + &output.diagnostics, + artifacts, + )) } fn load_provider_store(root: &Path, allow_missing: bool) -> Result, String> { @@ -2649,12 +2508,16 @@ fn format_file_json( stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> u8 { + let mut result = CommandOutput::new( + machine_output::Command::Fmt, + OutputMode::Json, + stdout, + stderr, + ); let source = match fs::read(path) { Ok(source) => source, Err(error) => { - return output_operational_error( - machine_output::Command::Fmt, - OutputMode::Json, + return result.operational_error( machine_output::IO_ERROR, format!( "cannot read '{}': {}", @@ -2664,27 +2527,22 @@ fn format_file_json( path, &[], Vec::new(), - stdout, - stderr, ); } }; let output = match Engine::bundled().format(&source) { Ok(output) => output, Err(error) => { - return output_operational_error( - machine_output::Command::Fmt, - OutputMode::Json, + return result.operational_error( machine_output::ENGINE_ERROR, format!("cannot format '{}': {error}", path.display()), path, &[], Vec::new(), - stdout, - stderr, ); } }; + let (_, stdout, stderr) = result.into_parts(); write_format_json_result( mode, path, @@ -2704,36 +2562,35 @@ fn format_stdin_json( stderr: &mut dyn Write, ) -> u8 { let diagnostics_path = Path::new(""); + let mut result = CommandOutput::new( + machine_output::Command::Fmt, + OutputMode::Json, + stdout, + stderr, + ); let mut source = Vec::new(); if stdin.read_to_end(&mut source).is_err() { - return output_operational_error( - machine_output::Command::Fmt, - OutputMode::Json, + return result.operational_error( machine_output::IO_ERROR, "cannot read standard input".to_owned(), diagnostics_path, &[], Vec::new(), - stdout, - stderr, ); } let output = match Engine::bundled().format(&source) { Ok(output) => output, Err(error) => { - return output_operational_error( - machine_output::Command::Fmt, - OutputMode::Json, + return result.operational_error( machine_output::ENGINE_ERROR, format!("cannot format stdin: {error}"), diagnostics_path, &[], Vec::new(), - stdout, - stderr, ); } }; + let (_, stdout, stderr) = result.into_parts(); write_format_json_result( mode, diagnostics_path, @@ -2757,35 +2614,33 @@ fn write_format_json_result( stdout: &mut dyn Write, stderr: &mut dyn Write, ) -> u8 { + let mut result = CommandOutput::new( + machine_output::Command::Fmt, + OutputMode::Json, + stdout, + stderr, + ); let has_errors = output .diagnostics .iter() .any(|diagnostic| diagnostic.severity == Severity::Error); let Some(formatted) = output.formatted_source else { if has_errors { - return write_machine_output( - machine_output::Envelope::result( - machine_output::Command::Fmt, - machine_output::Outcome::StackError, - EXIT_STACK_ERROR, - diagnostics_path, - &output.diagnostics, - Vec::new(), - ), - stdout, - stderr, - ); + return result.machine(machine_output::Envelope::result( + machine_output::Command::Fmt, + machine_output::Outcome::StackError, + EXIT_STACK_ERROR, + diagnostics_path, + &output.diagnostics, + Vec::new(), + )); } - return output_operational_error( - machine_output::Command::Fmt, - OutputMode::Json, + return result.operational_error( machine_output::INTERNAL_ERROR, "formatter produced no source or error diagnostic".to_owned(), diagnostics_path, &output.diagnostics, Vec::new(), - stdout, - stderr, ); }; let changed = formatted.as_bytes() != source; @@ -2798,26 +2653,20 @@ fn write_format_json_result( } else { (machine_output::Outcome::Success, EXIT_SUCCESS) }; - return write_machine_output( - machine_output::Envelope::result( - machine_output::Command::Fmt, - outcome, - exit_status, - diagnostics_path, - &output.diagnostics, - Vec::new(), - ), - stdout, - stderr, - ); + return result.machine(machine_output::Envelope::result( + machine_output::Command::Fmt, + outcome, + exit_status, + diagnostics_path, + &output.diagnostics, + Vec::new(), + )); } if changed { if let Err(error) = replace(formatted.as_bytes()) { let target = artifact_path.unwrap_or(diagnostics_path); - return output_operational_error( - machine_output::Command::Fmt, - OutputMode::Json, + return result.operational_error( machine_output::IO_ERROR, format!( "cannot replace '{}': {}", @@ -2827,8 +2676,6 @@ fn write_format_json_result( diagnostics_path, &output.diagnostics, Vec::new(), - stdout, - stderr, ); } } @@ -2843,18 +2690,14 @@ fn write_format_json_result( } else { (machine_output::Outcome::Success, EXIT_SUCCESS) }; - write_machine_output( - machine_output::Envelope::result( - machine_output::Command::Fmt, - outcome, - exit_status, - diagnostics_path, - &output.diagnostics, - artifacts, - ), - stdout, - stderr, - ) + result.machine(machine_output::Envelope::result( + machine_output::Command::Fmt, + outcome, + exit_status, + diagnostics_path, + &output.diagnostics, + artifacts, + )) } fn atomic_replace(path: &Path, contents: &[u8]) -> io::Result<()> { From 4ef211d4059f8677fbfbc2792a0c076b5040fcbc Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 19:38:19 +0900 Subject: [PATCH 3/3] Handle corrupted release archive compression errors --- scripts/package_release.py | 3 ++- scripts/test_package_release.py | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/package_release.py b/scripts/package_release.py index 8e8dfa7..94bf646 100644 --- a/scripts/package_release.py +++ b/scripts/package_release.py @@ -8,6 +8,7 @@ import stat import tarfile import tempfile +import zlib ROOT = Path(__file__).resolve().parent.parent @@ -243,7 +244,7 @@ def main(): arguments.binary, ) print(f"verified {result['archive']} ({result['entries']} entries, sha256:{result['sha256']})") - except (OSError, tarfile.TarError, ValueError) as error: + except (OSError, tarfile.TarError, ValueError, zlib.error) as error: raise SystemExit(str(error)) from error diff --git a/scripts/test_package_release.py b/scripts/test_package_release.py index 4bcd945..8c3019c 100644 --- a/scripts/test_package_release.py +++ b/scripts/test_package_release.py @@ -3,6 +3,7 @@ import tarfile import tempfile import unittest +import zlib from scripts.package_release import create_archive, verify_archive @@ -89,7 +90,9 @@ def test_modified_archive_is_rejected(self): contents = bytearray(archive.read_bytes()) contents[len(contents) // 2] ^= 0xFF archive.write_bytes(contents) - with self.assertRaises((gzip.BadGzipFile, tarfile.TarError, EOFError, OSError, ValueError)): + with self.assertRaises( + (gzip.BadGzipFile, tarfile.TarError, EOFError, OSError, ValueError, zlib.error) + ): verify_archive(archive, self.target, self.version, self.source_date_epoch, self.binary)