Skip to content

feat(structerr): structured JSON errors with typed interception and semantic exit codes#65

Merged
leodido merged 14 commits into
mainfrom
feat/structerr
Mar 28, 2026
Merged

feat(structerr): structured JSON errors with typed interception and semantic exit codes#65
leodido merged 14 commits into
mainfrom
feat/structerr

Conversation

@leodido
Copy link
Copy Markdown
Owner

@leodido leodido commented Mar 27, 2026

Summary

  • HandleError(cmd, err, writer) — classifies errors, writes JSON to stderr, returns semantic exit code
  • ExecuteOrExit(cmd) — one-line convenience for main(), auto-sets SilenceErrors/SilenceUsage, uses ExecuteC() for correct command resolution
  • SetupFlagErrors(rootCmd) — intercepts flag parsing errors via cobra.SetFlagErrorFunc producing typed FlagError values, eliminating regex for ~90% of error classification
  • structerr subpackage following config/debug/jsonschema pattern
  • Full source attribution: CLI flags vs env vars vs config
  • Rich validation output via ValidationError.Details() with per-field violations
  • Enum violation detection via EnumValuer annotations (exit code 15)

Two layers of error classification

Layer 1: SetupFlagErrors (typed, preferred)

Intercepts errors at creation time via cobra.SetFlagErrorFunc, producing typed FlagError values with structured metadata. HandleError uses errors.As — no regex needed.

Layer 2: Regex fallback (for cobra string errors)

When SetupFlagErrors is not called, or for error types cobra doesn't expose typed errors for (required flags, unknown commands), HandleError falls back to string pattern matching on cobra's error messages.

Usage

One-line:

func main() {
    rootCmd := buildMyCLI()
    structcli.SetupFlagErrors(rootCmd)           // optional: typed flag errors
    structcli.ExecuteOrExit(rootCmd)              // JSON errors to stderr, semantic exit codes
}

Manual control:

structcli.SetupFlagErrors(rootCmd)
cmd, err := rootCmd.ExecuteC()                   // ExecuteC returns the subcommand
if err != nil {
    os.Exit(structcli.HandleError(cmd, err, os.Stderr))
}

Important: HandleError requires the command where the error originated, not the root. Use ExecuteC() or ExecuteOrExit to get this right. See doc comment for details.

Error classification

Error source Detection JSON error Exit code
Missing required flag String pattern missing_required_flag 10
Invalid flag value (CLI) FlagError or string pattern + Changed() invalid_flag_value 11
Invalid enum value FlagError + EnumValuer annotation invalid_flag_enum 15
Invalid flag value (env) Source attribution via env annotation + os.Getenv env_invalid_value 25
Unknown flag FlagError or string pattern unknown_flag 12
Unknown command String pattern unknown_command 14
Validation failed errors.As on ValidationError + Details() validation_failed 13
Unmarshal decode error Mapstructure pattern + source attribution invalid_flag_value or env_invalid_value 11/25
Config errors String patterns config_* 20-23
Anything else Fallthrough error 1

Changes

File What
structerr/types.go (new) structerr.Options subpackage
structerr.go (new) SetupFlagErrors, HandleError, ExecuteOrExit, StructuredError, Violation, classification engine
structerr_test.go (new) Tests: typed path, regex fallback, every error category, source attribution, validation details, enum violations, edge cases
errors/errors.go FlagError type with Kind/FlagName/Value/Cause fields
examples/simple/main.go Uses ExecuteOrExit with SilenceErrors/SilenceUsage
examples/structerr/ (new) Dedicated example stress-testing every error path

Test coverage

Function Coverage
classify 100%
classifyUnmarshalError 100%
parseDecodeError 100%
flagType 100%
findFlagForField 100%
flagEnvVars 100%
flagEnumValues 100%
flagValidateRules 100%
extractLongFlagName 100%
parseQuotedList 100%
commandPath 100%
classifyMissingRequired 93%
classifyInvalidArg 92%
classifyUnknownCommand 86%
SetupFlagErrors 78%
HandleError 78%
classifyValidation 61%
ExecuteOrExit 0% (calls os.Exit)
leodido added 8 commits March 28, 2026 18:59
…N errors

Introduce HandleError() which classifies errors from cobra and structcli,
writes a JSON StructuredError to the provided writer, and returns a
semantic exit code from the exitcode package.

Error classification covers:
- Cobra string patterns: missing required flags, invalid flag values,
  unknown flags, unknown commands
- structcli typed errors: ValidationError, InputError
- Unmarshal/decode errors from mapstructure with field-level detail
- Config errors: unknown keys, parse errors, not found
- Source attribution: distinguishes CLI flag vs env var vs config origin
  using flag annotations and os.Getenv checks

ExecuteOrExit() is a convenience wrapper for the common main() pattern.
24 tests covering: nil error, missing required flags (with/without env
hints, single/multiple), invalid flag values (CLI and env var source
attribution), unknown flags, unknown commands (with available list),
validation errors (direct and wrapped), input errors, config errors
(unknown keys, not found, parse), unmarshal decode errors (env var
attribution, no env, unparseable), generic fallback, JSON output
validity, helper functions, and two integration tests using real
structcli Define/Unmarshal.
Replace manual Execute+log.Fatalln with structcli.ExecuteOrExit for
structured JSON error output. Add SilenceErrors and SilenceUsage to
demonstrate clean machine-readable error handling.
…is set

Cobra checks required flags before viper merges env vars. When an env
var IS set but cobra still fires "required flag not set", the env var
value is fine — cobra just doesn't see it. Report missing_required_flag
with a hint, not env_invalid_value.
…essages

- findFlagForField now matches via field path annotations, fixing
  cases where Go field names (LogLevel) differ from flag names (level)
- classifyUnmarshalError tries multiple regex patterns to handle
  different mapstructure error formats (parse errors, invalid string
  errors, generic decode errors)
- classifyInvalidArg now populates expected type from flag metadata
- Env error messages rewritten to be agent-friendly:
  "env var MYAPP_PORT: invalid value "xyz" for flag "port" (expected int)"
  instead of raw Go error internals
- Added flagType() helper and parseDecodeError() with three patterns
…erage

- Remove reDecodeFieldGeneric which captured wrong quoted string on
  multi-quote errors (eg. 'Port'...'config'...'bad' matched Port/config
  instead of Port/bad). Replace with reDecodeFieldName that safely
  extracts only the field name.
- Add tests for findFlagForField path annotation matching (LogLevel→level)
- Add TestParseDecodeError covering all three regex patterns + no match
- Add TestFindFlagForField covering direct name, path annotation, and miss
- Add TestFlagType covering int, string, and nonexistent flags
- Add TestExtractLongFlagName_Fallback for short-only flag specs
- Add TestHandleError_InvalidFlagValueFromEnvVar_CobraPath for cobra-path
  env attribution with expected type enrichment

Coverage: findFlagForField 57→100%, parseDecodeError 71→100%,
extractLongFlagName 75→100%, classifyUnmarshalError 93→100%.
…d detect enum errors

- classifyValidation now calls ve.Details() to extract structured Field, Rule,
  Param, and Value from each validation error instead of only the message
- Resolves Go struct field names to CLI flag names via findFlagForField
- Adds Param field to Violation struct for rule parameters (e.g., "18" for min=18)
- Adds flagEnumValues helper to read enum annotations from flags
- classifyInvalidArg and classifyUnmarshalError now check enum annotations
  and return exit code 15 (InvalidFlagEnum) when value is not in allowed set
- classifyMissingRequired enriches hint with "required by validation" when
  the flag has a validate annotation containing "required"
- Adds flagValidateRules and contains helpers
…ions

- TestHandleError_ValidationFailed_WithDetails: real validator errors produce
  Violation with Field (resolved to flag name), Rule, Param, Value
- TestHandleError_ValidationFailed_FieldToFlagMapping: StructField "LogLevel"
  maps to flag "level" via path annotation
- TestHandleError_InvalidFlagEnum: flag with enum annotation, bad value not in
  allowed set produces exit code 15 with available array
- TestHandleError_InvalidFlagEnum_ValidValue: enum takes precedence over type error
- TestHandleError_InvalidFlagEnum_UnmarshalPath: enum detection via unmarshal path
- TestHandleError_MissingRequiredFlagWithValidateHint: required flag with
  validate annotation includes "required by validation" in hint
- TestFlagEnumValues, TestFlagValidateRules, TestContains: unit tests for helpers
leodido added 5 commits March 28, 2026 21:35
Add FlagError type in errors/ with FlagErrorKind (InvalidValue, Unknown)
and structured metadata (FlagName, Value, Cause).

Add SetupFlagErrors() which hooks cobra's SetFlagErrorFunc to intercept
flag parsing errors at creation time and wrap them in typed FlagError
values. HandleError then uses errors.As to classify them — no regex at
classification time.

Regex-based classification is preserved as a fallback for CLIs that
don't call SetupFlagErrors.

5 new tests: typed invalid value, typed unknown flag, short flag
extraction, enum violation via typed path, regex fallback without setup.
…mand resolution

Add FlagError type with structured metadata (FlagName, Value, Kind,
CommandPath, ExpectedType, EnumValues, EnvVars) to errors/.

Add SetupFlagErrors() which hooks cobra's SetFlagErrorFunc to intercept
flag parsing errors at creation time and wrap them in typed FlagError
values with pre-populated metadata from the actual command. HandleError
then uses errors.As — no regex at classification time.

Switch ExecuteOrExit from Execute() to ExecuteC() which returns the
actual subcommand that failed, giving HandleError the correct flag set
for metadata lookups (type, env vars, enum values) and the correct
command path for error context.
…andling

Full example CLI with srv and usr subcommands showing SetupFlagErrors,
SetupJSONSchema, ExecuteOrExit, and ValidatableOptions. Comment header
lists every error scenario with expected exit codes.
Add 12 new tests covering: typed FlagError invalid value, unknown flag,
short flag extraction, enum violation, regex fallback without setup,
env var source attribution via typed path, validation with empty Details,
env var regex fallback path, unrecognized error format, CommandPath from
FlagError, and valid enum value not classified as violation.
…Exit

Simplify FlagError to carry only FlagName, Value, Kind, Cause — remove
CommandPath, ExpectedType, EnumValues, EnvVars fields that duplicated
metadata available from the command. Since ExecuteC() now gives
HandleError the correct subcommand, all enrichment (type lookup, enum
check, env attribution) happens once in classifyInvalidArg via the same
code path for both typed and regex errors.

ExecuteOrExit now automatically sets SilenceErrors and SilenceUsage on
the root command, so cobra doesn't print its own error messages or usage
text. Users no longer need to remember to set these manually.
@leodido leodido changed the title feat(structerr): add structured JSON error output with semantic exit codes feat(structerr): structured JSON errors with typed interception and semantic exit codes Mar 28, 2026
HandleError requires the command where the error originated, not
the root. Document why (flag metadata lookup), what goes wrong if
root is passed (degraded output), and how to get it right
(ExecuteOrExit or ExecuteC).
@leodido leodido merged commit 2682e26 into main Mar 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant