miller/pkg/climain/errors_json.go
John Kerl 78ed1563db
Add --errors-json structured error output (#2098) (#2113)
* Tier-2 structured verb options: OptionSpec, initial migration (#2098)

PR 3 of the AI-friendly roadmap (plans/plan-2098-llm.md).

Infrastructure:
- Add OptionSpec{Flag,Arg,Type,Desc,Repeatable,Values} to
  pkg/transformers/aaa_record_transformer.go alongside TransformerSetup.
  Type is one of: bool, string, int, float, csv-list, regex, filename,
  format, enum. For type=="enum", Values lists the valid choices.
- Add Options []OptionSpec to TransformerSetup (nil = not yet migrated).
- Emit Options in VerbInfoForJSON (omitempty so unmigrated verbs stay
  backward-compatible; agents check key presence for Tier-2 availability).
  UsageText is always present as the Tier-1 prose fallback.
- Add VerbOptionsNilCheck() in aaa_verb_options_check.go: progress report
  of migrated vs. unmigrated verbs, analogous to FLAG_TABLE.NilCheck().
- Wire verb-options-nil-check into mlr help (internal/docgen section).

Initial migration (5/70 verbs):
- nothing: empty Options (no verb-specific options, explicitly migrated)
- cat: -n (bool), -N (string), -g (csv-list), --filename, --filenum (bool)
- head: -g (csv-list), -n (int)
- tail: -g (csv-list), -n (int)
- tee: -a, -p (bool)

Tests:
- 5 new unit tests in aaa_transformer_json_test.go covering migrated/
  unmigrated paths, field population, JSON round-trip, and key-presence.
- Regression test case 0003: mlr help verb-options-nil-check golden output.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Migrate all 70 verbs to structured OptionSpec; bump catalog schema to v2

Completes the Tier-2 migration started in the previous commit. Every verb
in TRANSFORMER_LOOKUP_TABLE now has a non-nil Options field.

- Workflow-migrated all 65 remaining verbs. Each Setup var now carries
  Options: []OptionSpec{...} with Flag/Arg/Type/Desc fields. Verbs with
  no verb-specific options (altkv, check, group-like, nothing, etc.) use
  an empty slice to signal "migrated but no options."
- Drop `omitempty` from VerbInfoForJSON.Options: empty slices were silently
  dropped, making migrated-no-option verbs indistinguishable from unmigrated
  ones in JSON. Without omitempty: null=unmigrated, []=migrated-no-options,
  [...]= migrated-with-options. Bump catalogSchemaVersion 1→2 for this shape
  change.
- Replace the two "unmigrated-verb" unit tests (which used stats1 as an
  example) with TestAllVerbsFullyMigrated (asserts every verb has non-nil
  Options) and TestAllVerbsHaveOptionsKeyInJSON (asserts every migrated
  verb emits the "options" key in JSON).
- Regenerate test/cases/cli-help/0003/expout: now reads
  "Verb options migration: 70/70 migrated."

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

* Add --errors-json structured error output (#2098)

PR 4 of the AI-friendly roadmap (plans/plan-2098-llm.md).

Agents previously had to regex-match English prose to branch on error
kind; this PR lets them do it structurally.

Interface:
- mlr --errors-json <bad command> emits a JSON object to stderr and
  exits 1; same behavior as prose path but machine-readable.
- MLR_ERRORS_JSON=1 (truthy) is the env-var equivalent.
- Without the flag, prose output is byte-for-byte unchanged.

JSON shape: {error, kind, token, verb, hint, did_you_mean[]}
Error kinds: unknown-verb, unknown-flag, verb-option-error, generic.

Implementation:
- New pkg/climain/errors_json.go: CLIError typed error, StructuredError
  DTO, WantErrorsJSON pre-scan, Levenshtein edit distance, topMatches,
  EmitStructuredError.
- Convert two direct os.Exit sites in parseCommandLinePassOne (unknown
  verb, unknown flag) to return CLIError values; verb-option-error
  likewise returns CLIError from pass-one ParseCLIFunc handling.
- parseCommandLinePassOne gains an error return; ParseCommandLine
  propagates it.
- entrypoint.go pre-scans os.Args for --errors-json before calling
  ParseCommandLine; routes errors to EmitStructuredError or printError.
- did_you_mean: Levenshtein nearest-match over verb catalog, flag
  catalog, or the verb own OptionSpec flags (PR3 catalog) for
  verb-option-error. Closes the self-correction loop the catalog
  enables.
- --errors-json registered in Miscellaneous flags so it appears in
  mlr --help and is not treated as an unrecognized flag.

Tests: 16 unit tests covering Levenshtein, topMatches, threshold,
WantErrorsJSON, CLIError interface, and all categorize paths.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-07-03 14:49:48 -04:00

315 lines
8.1 KiB
Go

// Structured error output for `mlr --errors-json`.
//
// When --errors-json is set (or MLR_ERRORS_JSON is truthy), parse-time errors
// are emitted as a JSON object to stderr instead of a plain text message.
// This lets AI agents and scripts branch on error kind rather than regex-
// matching English prose.
//
// JSON shape:
// {
// "error": "mlr: verb \"flitre\" not found",
// "kind": "unknown-verb",
// "token": "flitre",
// "verb": "", // set for verb-option errors
// "hint": "Run 'mlr filter --help' for usage.",
// "did_you_mean": ["filter", "flatten"]
// }
package climain
import (
"encoding/json"
"errors"
"fmt"
"os"
"sort"
"strings"
"github.com/johnkerl/miller/v6/pkg/cli"
"github.com/johnkerl/miller/v6/pkg/dsl/cst"
"github.com/johnkerl/miller/v6/pkg/transformers"
)
// ----------------------------------------------------------------
// CLIError is the typed error returned by the pass-one parser for
// errors that carry structured metadata (unknown verb, unknown flag).
// Other parse errors still flow as plain errors; EmitStructuredError
// handles both.
type CLIError struct {
Kind string // "unknown-verb" | "unknown-flag"
Token string // the unrecognized token
Verb string // enclosing verb context, if any
Msg string // human-readable message (same as what prose path would print)
}
func (e *CLIError) Error() string { return e.Msg }
// ----------------------------------------------------------------
// StructuredError is the JSON DTO emitted to stderr.
type StructuredError struct {
Error string `json:"error"`
Kind string `json:"kind"`
Token string `json:"token,omitempty"`
Verb string `json:"verb,omitempty"`
Hint string `json:"hint,omitempty"`
DidYouMean []string `json:"did_you_mean,omitempty"`
}
// ----------------------------------------------------------------
// Opt-in detection
// WantErrorsJSON returns true when the caller has opted in via the
// --errors-json flag anywhere in args, or a truthy MLR_ERRORS_JSON env var.
// It is called before ParseCommandLine so it can affect how parse errors
// are reported.
func WantErrorsJSON(args []string) bool {
if isTruthyEnv(os.Getenv("MLR_ERRORS_JSON")) {
return true
}
for _, arg := range args {
if arg == "--errors-json" {
return true
}
}
return false
}
func isTruthyEnv(v string) bool {
switch v {
case "1", "true", "True", "TRUE", "yes", "Yes", "YES":
return true
}
return false
}
// ----------------------------------------------------------------
// Error categorization and emission
// EmitStructuredError writes a JSON error document to stderr and exits 1.
// It is called in place of printError when --errors-json is active.
func EmitStructuredError(err error) {
se := categorize(err)
se.DidYouMean = nearMatches(se)
b, jerr := json.MarshalIndent(se, "", " ")
if jerr != nil {
// Fallback: plain text if we somehow can't marshal
fmt.Fprintf(os.Stderr, "mlr: %v\n", err)
return
}
fmt.Fprintln(os.Stderr, string(b))
}
// categorize turns any error into a StructuredError, using the typed CLIError
// fields when available and string-matching as a fallback.
func categorize(err error) StructuredError {
var cliErr *CLIError
if errors.As(err, &cliErr) {
se := StructuredError{
Error: cliErr.Msg,
Kind: cliErr.Kind,
Token: cliErr.Token,
Verb: cliErr.Verb,
}
switch cliErr.Kind {
case "unknown-verb":
se.Hint = "Run 'mlr -l' for a list of verbs, or 'mlr help verb <name>' for details."
case "unknown-flag":
se.Hint = "Run 'mlr --help' for a list of main flags."
case "verb-option-error":
if cliErr.Verb != "" {
se.Hint = "Run 'mlr " + cliErr.Verb + " --help' for a list of options."
}
}
return se
}
// Fallback: categorize by message pattern.
msg := err.Error()
// "mlr {verb}: option "{flag}" not recognized"
if strings.Contains(msg, ": option ") && strings.Contains(msg, "not recognized") {
verb := extractVerbFromMsg(msg)
token := extractQuotedToken(msg)
return StructuredError{
Error: msg,
Kind: "verb-option-error",
Token: token,
Verb: verb,
Hint: fmt.Sprintf("Run 'mlr %s --help' for a list of options.", verb),
}
}
// DSL parse errors
if strings.Contains(msg, "cannot parse DSL") || strings.Contains(msg, "DSL expression") {
return StructuredError{
Error: msg,
Kind: "dsl-parse-error",
Hint: "Run 'mlr put --help' for DSL syntax reference.",
}
}
return StructuredError{
Error: msg,
Kind: "generic",
}
}
// nearMatches populates did_you_mean for the structured error.
func nearMatches(se StructuredError) []string {
if se.Token == "" {
return nil
}
query := strings.ToLower(se.Token)
var candidates []string
switch se.Kind {
case "unknown-verb":
candidates = transformers.GetVerbNames()
case "unknown-flag":
candidates = cli.FLAG_TABLE.GetFlagNames()
case "verb-option-error":
// Use structured OptionSpec when available (from PR3 catalog).
if se.Verb != "" {
if info := transformers.GetVerbInfoForJSON(se.Verb); info != nil {
for _, opt := range info.Options {
candidates = append(candidates, opt.Flag)
}
}
}
if len(candidates) == 0 {
candidates = cli.FLAG_TABLE.GetFlagNames()
}
case "dsl-parse-error":
candidates = append(cst.BuiltinFunctionManagerInstance.GetBuiltinFunctionNames(),
cst.GetKeywordNames()...)
}
return topMatches(query, candidates, 3, levenshteinThreshold(query))
}
// topMatches returns up to n candidates from the list sorted by edit distance,
// keeping only those within maxDist of the query.
func topMatches(query string, candidates []string, n, maxDist int) []string {
type scored struct {
name string
dist int
}
var scored_ []scored
for _, c := range candidates {
d := levenshtein(query, strings.ToLower(c))
if d <= maxDist {
scored_ = append(scored_, scored{c, d})
}
}
sort.Slice(scored_, func(i, j int) bool {
if scored_[i].dist != scored_[j].dist {
return scored_[i].dist < scored_[j].dist
}
return scored_[i].name < scored_[j].name
})
result := make([]string, 0, n)
for i, s := range scored_ {
if i >= n {
break
}
result = append(result, s.name)
}
return result
}
// levenshteinThreshold returns the maximum edit distance to consider a match.
// Shorter queries use a tighter threshold.
func levenshteinThreshold(query string) int {
n := len(query)
switch {
case n <= 3:
return 1
case n <= 6:
return 2
default:
return 3
}
}
// ----------------------------------------------------------------
// Levenshtein edit distance (Wagner-Fischer, O(m*n) time, O(n) space)
func levenshtein(a, b string) int {
ra, rb := []rune(a), []rune(b)
la, lb := len(ra), len(rb)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := range prev {
prev[j] = j
}
for i, ca := range ra {
curr[0] = i + 1
for j, cb := range rb {
cost := 1
if ca == cb {
cost = 0
}
curr[j+1] = min3(curr[j]+1, prev[j+1]+1, prev[j]+cost)
}
prev, curr = curr, prev
}
return prev[lb]
}
func min3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
// ----------------------------------------------------------------
// Helpers for string-based categorization
// extractTokenFromVerbError extracts the quoted token from a verb error
// such as `mlr cut: option "--foo" not recognized`.
// Used in mlrcli_parse.go to populate CLIError.Token from the error string.
func extractTokenFromVerbError(msg string) string {
return extractQuotedToken(msg)
}
// extractVerbFromMsg extracts the verb from "mlr {verb}: ..." error messages.
func extractVerbFromMsg(msg string) string {
// Pattern: "mlr {verb}: ..."
msg = strings.TrimPrefix(msg, "mlr ")
if i := strings.Index(msg, ":"); i > 0 {
return msg[:i]
}
return ""
}
// extractQuotedToken extracts the first double-quoted token from a message.
func extractQuotedToken(msg string) string {
start := strings.Index(msg, "\"")
if start < 0 {
return ""
}
end := strings.Index(msg[start+1:], "\"")
if end < 0 {
return ""
}
return msg[start+1 : start+1+end]
}