diff --git a/docs/structured-output.md b/docs/structured-output.md index 4030305c..3319030d 100644 --- a/docs/structured-output.md +++ b/docs/structured-output.md @@ -79,7 +79,7 @@ Within the main payload, the `error` field contains the following sub-fields: | Field | Type | Notes | |---|---|---| | `code` | string | One of the enumerated codes below. Never free text — see the error-codes table. The primary, stable identifier — branch on this for anything specific. | -| `category` | string | One of 7 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~7 values instead of `code`'s ~28, while a caller that already keys off a specific `code` is unaffected. | +| `category` | string | One of 8 coarse groupings of `code` (`RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, `IAC`) — see [Error categories](#error-categories) below. Additive alongside `code`, not a replacement for it: a caller that only wants broad handling can switch on `category`'s ~8 values instead of `code`'s ~32, while a caller that already keys off a specific `code` is unaffected. | | `message` | string | Human-readable headline, informational only. **Not guaranteed stable across versions** — scripts must branch on `code`, not `message`. | | `retryable` | bool | A static property of `code` (not computed per failure) — see below. Note this is independent of `category`: a category can contain both retryable and non-retryable codes (e.g. `RUNTIME` contains both `NETWORK_ERROR` [retryable] and `DNS_RESOLUTION_REQUIRED` [not]), so `retryable` can't be inferred from `category` alone. | | `details` | object | Optional, code-specific structured context. Omitted when empty. Illustrative example, for a future `SNAPSHOT_BUCKET_NOT_FOUND`: `{"bucket": "my-terraform-state"}`. Also where the additional diagnostic depth plain text and the TUI show alongside the `message` headline lands, as `summary`/`detail` string keys, when available — e.g. `{"summary": "cannot connect to Docker daemon: ..."}` for `RUNTIME_UNAVAILABLE`. | @@ -121,10 +121,14 @@ Every `error.code` is one of the following fixed constants. A failure that doesn | `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | `RUNTIME` | | `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` | | `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` | +| `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` | +| `IAC_NO_TOOL_DETECTED` | No IaC tool could be resolved for the workspace: nothing matched, and no `--tool` was given | No | `IAC` | +| `IAC_TOOL_AMBIGUOUS` | More than one IaC tool matched the workspace and no choice could be made (no `--tool`, and nobody to prompt) | No | `IAC` | +| `IAC_DEPLOY_FAILED` | A delegated deployment command (e.g. `terraform apply`) exited non-zero; the tool's own status is reported in `error.details` | No | `IAC` | ### Error categories -`error.category` groups the 28 codes above into 7 buckets, additive alongside `code` — it exists purely so a caller that only wants coarse handling doesn't have to build and maintain its own mapping from all 28 codes. `code` is unaffected and remains the primary, stable identifier for anything more specific. +`error.category` groups the 29 codes above into 8 buckets, additive alongside `code` — it exists purely so a caller that only wants coarse handling doesn't have to build and maintain its own mapping from all 29 codes. `code` is unaffected and remains the primary, stable identifier for anything more specific. ``` RUNTIME RUNTIME_UNAVAILABLE, IMAGE_PULL_FAILED, DEPENDENCY_MISSING, @@ -154,10 +158,17 @@ USAGE CONFIRMATION_REQUIRED, VALIDATION_ERROR, USAGE_ERROR, INTERNAL CANCELLED, INTERNAL_ERROR → catch-all: unexpected failure, or a user-initiated interruption + +IAC IAC_FILE_NOT_FOUND, IAC_NO_TOOL_DETECTED, IAC_TOOL_AMBIGUOUS, + IAC_DEPLOY_FAILED + → the workspace's infrastructure-as-code is the problem (the + domain `lstk deploy` operates in) ``` Every code maps to exactly one category, and the mapping is static — the same code always reports the same category, regardless of which command emitted it (see the Category column above for the authoritative per-code mapping). +`IAC` is the first category no built-in command emits today: `lstk deploy` is a bundled extension, and an extension renders its own envelope (see the note on extension dispatch under [Commands that will never support `--json`](#commands-that-will-never-support---json)). The vocabulary is enumerated here anyway, because the whole point of a closed enumeration is that a consumer can switch on it exhaustively without caring which binary produced the envelope — an extension's output is meant to be indistinguishable from a built-in's. + ## Exit codes ``` diff --git a/internal/output/error_code.go b/internal/output/error_code.go index ee10d7cc..e1314847 100644 --- a/internal/output/error_code.go +++ b/internal/output/error_code.go @@ -34,6 +34,10 @@ const ( ErrNetworkError ErrorCode = "NETWORK_ERROR" ErrCancelled ErrorCode = "CANCELLED" ErrInternal ErrorCode = "INTERNAL_ERROR" + ErrIACFileNotFound ErrorCode = "IAC_FILE_NOT_FOUND" + ErrIACNoToolDetected ErrorCode = "IAC_NO_TOOL_DETECTED" + ErrIACToolAmbiguous ErrorCode = "IAC_TOOL_AMBIGUOUS" + ErrIACDeployFailed ErrorCode = "IAC_DEPLOY_FAILED" ) // retryableCodes is the single source of truth for whether a given ErrorCode @@ -60,7 +64,7 @@ func (c ErrorCode) Retryable() bool { // additive alongside Code (not a replacement for it — see design.md's // naming decisions). A caller that only wants to distinguish broad kinds of // failure (an environment problem vs. a usage problem vs. an auth problem) -// can switch on the ~7 Category values instead of the ~28 Code values; +// can switch on the ~8 Category values instead of the ~32 Code values; // Code remains the primary, stable identifier for anything more specific. type ErrorCategory string @@ -84,6 +88,9 @@ const ( // CategoryInternal: catch-all — unexpected failure or user-initiated // interruption. CategoryInternal ErrorCategory = "INTERNAL" + // CategoryIAC: the workspace's infrastructure-as-code is the problem — + // the domain `lstk deploy` operates in. + CategoryIAC ErrorCategory = "IAC" ) // allErrorCodes lists every defined ErrorCode, so tests can assert every code @@ -118,6 +125,10 @@ var allErrorCodes = []ErrorCode{ ErrNetworkError, ErrCancelled, ErrInternal, + ErrIACFileNotFound, + ErrIACNoToolDetected, + ErrIACToolAmbiguous, + ErrIACDeployFailed, } // categoryByCode is the single source of truth mapping each ErrorCode to its @@ -152,6 +163,10 @@ var categoryByCode = map[ErrorCode]ErrorCategory{ ErrNotJSONCapable: CategoryUsage, ErrCancelled: CategoryInternal, ErrInternal: CategoryInternal, + ErrIACFileNotFound: CategoryIAC, + ErrIACNoToolDetected: CategoryIAC, + ErrIACToolAmbiguous: CategoryIAC, + ErrIACDeployFailed: CategoryIAC, } // Category reports the code's static, coarse grouping. Every ErrorCode in diff --git a/internal/output/error_code_test.go b/internal/output/error_code_test.go index 0a0fd0ec..e2b1f2e2 100644 --- a/internal/output/error_code_test.go +++ b/internal/output/error_code_test.go @@ -31,8 +31,8 @@ func TestErrorCode_AllErrorCodesIsComplete(t *testing.T) { t.Errorf("ErrorCode %q appears %d times in allErrorCodes, want exactly once", code, count) } } - if len(allErrorCodes) != 28 { - t.Errorf("expected 28 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes)) + if len(allErrorCodes) != 32 { + t.Errorf("expected 32 documented error codes, got %d — update this test's expectation alongside error-codes/spec.md if a code was intentionally added or removed", len(allErrorCodes)) } } @@ -56,6 +56,10 @@ func TestErrorCode_Category(t *testing.T) { {ErrUsageError, CategoryUsage}, {ErrCancelled, CategoryInternal}, {ErrInternal, CategoryInternal}, + {ErrIACFileNotFound, CategoryIAC}, + {ErrIACNoToolDetected, CategoryIAC}, + {ErrIACToolAmbiguous, CategoryIAC}, + {ErrIACDeployFailed, CategoryIAC}, } for _, c := range cases { if got := c.code.Category(); got != c.want { diff --git a/openspec/changes/json-output-schema/specs/error-codes/spec.md b/openspec/changes/json-output-schema/specs/error-codes/spec.md index 00297682..19c15cad 100644 --- a/openspec/changes/json-output-schema/specs/error-codes/spec.md +++ b/openspec/changes/json-output-schema/specs/error-codes/spec.md @@ -33,6 +33,10 @@ Every `error.code` value emitted in a JSON envelope SHALL be one of a fixed, doc | `NETWORK_ERROR` | An unclassified network/transport failure occurred | Yes | `RUNTIME` | | `CANCELLED` | The operation was interrupted (e.g. context cancellation via Ctrl+C) | Yes | `INTERNAL` | | `INTERNAL_ERROR` | Unclassified or unexpected failure; the universal fallback | No | `INTERNAL` | +| `IAC_FILE_NOT_FOUND` | A required infrastructure-as-code file or directory does not exist or cannot be read (e.g. the workspace `lstk deploy detect --dir` was pointed at) | No | `IAC` | +| `IAC_NO_TOOL_DETECTED` | No IaC tool could be resolved for the workspace: nothing matched, and no `--tool` was given | No | `IAC` | +| `IAC_TOOL_AMBIGUOUS` | More than one IaC tool matched the workspace and no choice could be made (no `--tool`, and nobody to prompt) | No | `IAC` | +| `IAC_DEPLOY_FAILED` | A delegated deployment command (e.g. `terraform apply`) exited non-zero; the tool's own status is reported in `error.details` | No | `IAC` | #### Scenario: Error code is one of the documented constants - **WHEN** any JSON-capable command emits an `error` object @@ -65,11 +69,11 @@ Every `error` object SHALL include a `retryable` boolean, a static property of ` - **THEN** both error objects report the same `retryable` value for that code ### Requirement: Error objects declare a coarse category, additive alongside code -Every `error` object SHALL include a `category` string, a static property of `code` (the same code always carries the same category, per the table above) drawn from a fixed, small set: `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`. `category` is additive: it exists so a caller that only wants to distinguish broad kinds of failure can switch on roughly 7 values instead of the full code list, without requiring `code` to change meaning or cardinality — `code` remains the primary, stable identifier for anything more specific, and every existing rule keyed on `code` (the exit-code reservations, `retryable`, every scenario elsewhere in this document) is unaffected by `category`'s presence. +Every `error` object SHALL include a `category` string, a static property of `code` (the same code always carries the same category, per the table above) drawn from a fixed, small set: `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, `IAC`. `category` is additive: it exists so a caller that only wants to distinguish broad kinds of failure can switch on roughly 8 values instead of the full code list, without requiring `code` to change meaning or cardinality — `code` remains the primary, stable identifier for anything more specific, and every existing rule keyed on `code` (the exit-code reservations, `retryable`, every scenario elsewhere in this document) is unaffected by `category`'s presence. #### Scenario: Category is one of the documented constants - **WHEN** any JSON-capable command emits an `error` object -- **THEN** `error.category` is exactly one of `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, or `INTERNAL` +- **THEN** `error.category` is exactly one of `RUNTIME`, `EMULATOR`, `AUTH`, `RESOURCE`, `CONFIG`, `USAGE`, `INTERNAL`, or `IAC` #### Scenario: Category is consistent for a given code - **WHEN** the same `error.code` is emitted by two different commands