mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
be2a96f490
Aggregate the sheets work from feat/lark-sheets-develop: - Improve validation errors with schema hints, aggregated issues, enum guidance, and prescriptive flag/style-field messages. - Harden +batch-update input contracts, key normalization, style vocabulary handling, and resource-budget checks. - Add read offload and truncation handling for cells, csv, and table-get, with typed output-path errors and safer jq/output-path semantics. - Correct freeze semantics by emitting full-state freeze/unfreeze operations and adding --rows/--cols for +dim-freeze. - Improve +styles-put and shared --styles parsing for styles, merges, row/column sizing, freeze, and sheet-prefixed range validation. - Fix dim-insert inherit-style mapping, table-get date/time handling, table-put style anchors, and CSV path-shaped input guards. - Update lark-sheets skill docs, scripts, tests, and generated flag data. Tested with: - go test ./shortcuts/common ./shortcuts/sheets/... - go test ./shortcuts/... ./internal/... - python3 -m py_compile skills/lark-sheets/scripts/*.py
880 lines
31 KiB
Go
880 lines
31 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package sheets
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/larksuite/cli/internal/suggest"
|
|
)
|
|
|
|
// ─── schema-driven flag validation ────────────────────────────────────
|
|
//
|
|
// Composite JSON flags (--properties, --cells, --operations, …) carry
|
|
// non-trivial payloads whose shape is already pinned by the embedded
|
|
// data/flag-schemas.json (see flag_schema.go). Rather than hand-write
|
|
// per-spec validators for type / enum / required / nested checks, every
|
|
// such flag is run through validatePropertiesAgainstSchema after the
|
|
// shortcut's enhance hook has filled in any flat-flag-derived fields
|
|
// (schema describes the *final* tool input, not the raw --properties
|
|
// JSON the user typed). Cross-field business rules that JSON Schema
|
|
// can't express (e.g. sparkline-update requires sparkline_id per item)
|
|
// continue to live in spec.validateUpdateInput.
|
|
//
|
|
// The rule set is a subset of ai-tools/.../validate-tool-params.ts —
|
|
// type, enum, oneOf, required, nested properties, and array items.
|
|
// additionalProperties is intentionally lenient: the embedded schema
|
|
// is a sub-tree and may not be exhaustive, so rejecting unknown keys
|
|
// would be more disruptive than valuable.
|
|
|
|
// validateParsedJSONFlag validates the just-parsed value of a single
|
|
// JSON flag against its embedded schema, if one is registered for the
|
|
// (command, flag) pair. Called from parseJSONFlag so every JSON flag
|
|
// — sort-keys, options, border-styles, cells, operations, ranges, … —
|
|
// is checked at the user-input boundary, in user-input shape.
|
|
//
|
|
// `properties` is intentionally skipped here: its schema describes the
|
|
// *final* tool-input properties (the shape after enhance* hooks
|
|
// inject flat-flag-derived fields such as cond-format's rule_type),
|
|
// not what the user typed under --properties. The input-builder tail
|
|
// validates that one via validateInputAgainstSchema after enhance.
|
|
func validateParsedJSONFlag(fv flagView, name string, value interface{}) error {
|
|
if fv == nil || value == nil {
|
|
return nil
|
|
}
|
|
if _, skip := parseJSONFlagSkip[name]; skip {
|
|
return nil
|
|
}
|
|
return validateValueAgainstSchema(fv, name, value)
|
|
}
|
|
|
|
// parseJSONFlagSkip lists flag names where parseJSONFlag-time schema
|
|
// validation is intentionally bypassed:
|
|
//
|
|
// - properties: schema describes the *final* tool-input shape (after
|
|
// enhance hooks inject flat-flag-derived fields); validated at the
|
|
// input-builder tail via validateInputAgainstSchema instead.
|
|
// - operations: +batch-update's translator does richer validation
|
|
// (allowed-shortcut allow-list, fan-out rejection, …) with more
|
|
// actionable error messages than a generic "not in enum [...]"
|
|
// would. The translator path stays the source of truth.
|
|
var parseJSONFlagSkip = map[string]struct{}{
|
|
"properties": {},
|
|
"operations": {},
|
|
"styles": {},
|
|
}
|
|
|
|
// validateValueAgainstSchema is the (command, flag) → schema → check
|
|
// pipeline shared by both validateParsedJSONFlag (user shape) and
|
|
// validateInputAgainstSchema (wire shape).
|
|
func validateValueAgainstSchema(fv flagView, name string, value interface{}) error {
|
|
command := fv.Command()
|
|
if command == "" {
|
|
return nil
|
|
}
|
|
// Fast path: commands without a registered schema can't fail this check,
|
|
// so skip the 256KB flag-schemas.json parse entirely for them.
|
|
if _, ok := commandsWithSchema[command]; !ok {
|
|
return nil
|
|
}
|
|
idx, _ := loadFlagSchemas()
|
|
if idx == nil {
|
|
return nil
|
|
}
|
|
entry, ok := idx.Flags[command]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
raw, ok := entry[name]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var schema schemaProperty
|
|
json.Unmarshal(raw, &schema)
|
|
c := &schemaErrorCollector{}
|
|
collectSchemaErrors(value, &schema, "", c)
|
|
if len(c.errs) == 0 {
|
|
return nil
|
|
}
|
|
vErr := c.errs[0]
|
|
if len(c.errs) == 1 {
|
|
// Single failure keeps the historical message byte-for-byte.
|
|
//
|
|
// Composite-JSON shape errors (e.g. +cells-set --cells, chart
|
|
// --properties) are the highest-frequency usage-layer failure for
|
|
// sheets, and agents often burn several retries guessing the shape.
|
|
// A shallow type mismatch means the caller misremembered the overall
|
|
// container shape (the classic {"cells": ...} wrapper around what
|
|
// must be a bare 2D array), so inline a skeleton of the expected
|
|
// shape — that fixes the retry without a --print-schema round trip.
|
|
// Deeper failures keep the --print-schema pointer, which dumps the
|
|
// exact JSON Schema for this (command, flag) pair; reaching this
|
|
// branch means entry[name] resolved a schema from the embedded
|
|
// index, so the suggested command is guaranteed to print it.
|
|
// An enum-bearing field states its own contract far better than a
|
|
// whole-payload skeleton, at any depth: --border-styles with
|
|
// weight:1 used to answer with {"bottom": {…}, "left": {…}, …},
|
|
// which says nothing about thin/medium/thick. Let those fall
|
|
// through to the hintSuffix path below, which names the enum.
|
|
var tm *typeMismatchError
|
|
isTypeMismatch := errors.As(vErr, &tm)
|
|
if isTypeMismatch && len(tm.enum) == 0 && pathDepth(tm.path) <= skeletonPathDepthLimit {
|
|
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
|
|
return sheetsValidationForFlag(name,
|
|
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
|
|
name, vErr.Error(), sk, command, name).WithCause(vErr)
|
|
}
|
|
}
|
|
// Deep type mismatches don't get a whole-shape skeleton (it wouldn't
|
|
// address the actual field), but if the field itself carries an enum /
|
|
// description, append that one line — same "fix on first retry" goal.
|
|
msg := vErr.Error()
|
|
if isTypeMismatch {
|
|
if suffix := tm.hintSuffix(); suffix != "" {
|
|
msg += "; " + suffix
|
|
}
|
|
}
|
|
return sheetsValidationForFlag(name,
|
|
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
|
name, msg, command, name).WithCause(vErr)
|
|
}
|
|
// Multiple failures: report them all at once (numbered, each with its
|
|
// own inline teaching hint) so the agent fixes the whole payload in one
|
|
// retry instead of the fail-fast "fix one, hit the next" loop.
|
|
return sheetsValidationForFlag(name,
|
|
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
|
|
name, formatSchemaErrorList(c.errs), command, name).WithCause(vErr)
|
|
}
|
|
|
|
// formatSchemaErrorList renders collected failures as a numbered one-line
|
|
// list: "N validation errors: 1) …; 2) …". Type-mismatch entries carry
|
|
// their enum/description suffix just like the single-error path. Entries
|
|
// beyond schemaErrorDisplayLimit collapse into a "(more …)" tail — the
|
|
// collector stops at cap, so the exact total is unknown by design.
|
|
func formatSchemaErrorList(errs []error) string {
|
|
shown := errs
|
|
truncated := false
|
|
if len(shown) > schemaErrorDisplayLimit {
|
|
shown = shown[:schemaErrorDisplayLimit]
|
|
truncated = true
|
|
}
|
|
parts := make([]string, 0, len(shown))
|
|
for i, e := range shown {
|
|
msg := e.Error()
|
|
var tm *typeMismatchError
|
|
if errors.As(e, &tm) {
|
|
if suffix := tm.hintSuffix(); suffix != "" {
|
|
msg += "; " + suffix
|
|
}
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%d) %s", i+1, msg))
|
|
}
|
|
out := fmt.Sprintf("%d validation errors: %s", len(shown), strings.Join(parts, "; "))
|
|
if truncated {
|
|
out = fmt.Sprintf("%d+ validation errors: %s; (more errors not shown — fix these first)", schemaErrorDisplayLimit, strings.Join(parts, "; "))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// validateInputAgainstSchema validates input[flag] for every flag the
|
|
// embedded schema registers under the view's shortcut command. Returns
|
|
// nil when no schema is registered for the command, or when none of
|
|
// the registered flag names appear in `input` (schema describes the
|
|
// shape of values when they are present, not which flags must be
|
|
// present). Designed to be called at the tail of every input builder
|
|
// so wiring up a new shortcut requires only the standard one-line
|
|
// invocation, not a per-shortcut validator.
|
|
func validateInputAgainstSchema(fv flagView, input map[string]interface{}) error {
|
|
if fv == nil || input == nil {
|
|
return nil
|
|
}
|
|
command := fv.Command()
|
|
if command == "" {
|
|
return nil
|
|
}
|
|
// Fast path: commands without a registered schema have nothing to
|
|
// validate, so skip the 256KB flag-schemas.json parse entirely.
|
|
if _, ok := commandsWithSchema[command]; !ok {
|
|
return nil
|
|
}
|
|
idx, _ := loadFlagSchemas()
|
|
if idx == nil {
|
|
return nil
|
|
}
|
|
entry, ok := idx.Flags[command]
|
|
if !ok || len(entry) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Deterministic order so error messages are stable across runs.
|
|
flagNames := make([]string, 0, len(entry))
|
|
for name := range entry {
|
|
flagNames = append(flagNames, name)
|
|
}
|
|
sort.Strings(flagNames)
|
|
|
|
for _, flagName := range flagNames {
|
|
if _, skip := inputSchemaSkip[flagName]; skip {
|
|
continue
|
|
}
|
|
// Input keys are wire-style (underscore); schema keys are CLI-style
|
|
// (hyphen) — translate before lookup. Flags whose wire form lives
|
|
// under a different key (e.g. --sort-keys → sort_conditions) won't
|
|
// be found here; they're already validated in user shape via
|
|
// parseJSONFlag → validateParsedJSONFlag.
|
|
inputKey := strings.ReplaceAll(flagName, "-", "_")
|
|
value, present := input[inputKey]
|
|
if !present {
|
|
continue
|
|
}
|
|
if err := validateValueAgainstSchema(fv, flagName, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// inputSchemaSkip mirrors parseJSONFlagSkip for the input-builder
|
|
// tail. Same rationale: bypass schema validation for flags where
|
|
// richer translator-side validation owns the contract (operations).
|
|
var inputSchemaSkip = map[string]struct{}{
|
|
"operations": {},
|
|
}
|
|
|
|
// schemaProperty mirrors the JSON Schema subset used by
|
|
// data/flag-schemas.json. Description is retained (not just documentation)
|
|
// so a required-missing or type-mismatch error can inline the one-line
|
|
// field doc — the agent then fixes the input without a --print-schema round
|
|
// trip. Other unknown keys stay dropped.
|
|
//
|
|
// Minimum / Maximum / MinItems / MaxItems use *float64 / *int because
|
|
// 0 is a meaningful bound (e.g. chart row >= 0); nil distinguishes
|
|
// "no bound declared" from "bound is zero".
|
|
//
|
|
// AdditionalProperties handles the JSON Schema three-way:
|
|
// - absent / true → lenient, any extra key allowed (validator's
|
|
// default; matches the file header's "may not be exhaustive"
|
|
// stance for schemas that simply don't declare it).
|
|
// - false → strict, every extra key rejected.
|
|
// - <schema> → extra keys allowed, but each value must validate
|
|
// against this schema. Used today for pivot's dynamic
|
|
// map<string, array<string>> fields (groups / collapse).
|
|
type schemaProperty struct {
|
|
Type string `json:"type"`
|
|
Description string `json:"description"`
|
|
Nullable bool `json:"nullable"`
|
|
Enum []interface{} `json:"enum"`
|
|
Properties map[string]*schemaProperty `json:"properties"`
|
|
Required []string `json:"required"`
|
|
Items *schemaProperty `json:"items"`
|
|
OneOf []*schemaProperty `json:"oneOf"`
|
|
Minimum *float64 `json:"minimum"`
|
|
Maximum *float64 `json:"maximum"`
|
|
MinItems *int `json:"minItems"`
|
|
MaxItems *int `json:"maxItems"`
|
|
AdditionalProperties *additionalProps `json:"additionalProperties"`
|
|
}
|
|
|
|
// additionalProps captures the three JSON Schema forms of
|
|
// `additionalProperties`. UnmarshalJSON decodes true / false / object
|
|
// into the same struct so callers can branch on (Strict, Schema).
|
|
type additionalProps struct {
|
|
Strict bool // true when schema declared additionalProperties:false
|
|
Schema *schemaProperty // non-nil when declared as an object schema
|
|
}
|
|
|
|
func (a *additionalProps) UnmarshalJSON(data []byte) error {
|
|
trimmed := strings.TrimSpace(string(data))
|
|
switch trimmed {
|
|
case "true":
|
|
return nil // lenient — same as absent
|
|
case "false":
|
|
a.Strict = true
|
|
return nil
|
|
}
|
|
var sub schemaProperty
|
|
if err := json.Unmarshal(data, &sub); err != nil {
|
|
return err
|
|
}
|
|
a.Schema = &sub
|
|
return nil
|
|
}
|
|
|
|
// schemaErrorCollector accumulates validation failures during one full
|
|
// traversal so the caller can report every problem in a single reply
|
|
// instead of the fail-fast "fix one, retry, hit the next" loop. Capacity
|
|
// is bounded (collectSchemaErrorsCap) so a pathological payload — e.g. a
|
|
// 5000-row --cells array where every cell is malformed — cannot balloon
|
|
// the error message or the traversal cost: once full, collection
|
|
// short-circuits everywhere via full().
|
|
type schemaErrorCollector struct {
|
|
errs []error
|
|
}
|
|
|
|
// collectSchemaErrorsCap bounds how many errors one traversal gathers:
|
|
// schemaErrorDisplayLimit entries are rendered; one extra is collected
|
|
// only to know that truncation happened.
|
|
const (
|
|
schemaErrorDisplayLimit = 5
|
|
collectSchemaErrorsCap = schemaErrorDisplayLimit + 1
|
|
)
|
|
|
|
func (c *schemaErrorCollector) add(err error) {
|
|
if len(c.errs) < collectSchemaErrorsCap {
|
|
c.errs = append(c.errs, err)
|
|
}
|
|
}
|
|
|
|
func (c *schemaErrorCollector) full() bool { return len(c.errs) >= collectSchemaErrorsCap }
|
|
|
|
// validateAgainstSchema recursively checks `value` against `schema`,
|
|
// prefixing any failure with the JSON path navigated so far. It reports
|
|
// only the first failure — callers that want the full list (the
|
|
// error-as-teaching aggregate path) use collectSchemaErrors directly.
|
|
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
|
|
c := &schemaErrorCollector{}
|
|
collectSchemaErrors(value, schema, path, c)
|
|
if len(c.errs) == 0 {
|
|
return nil
|
|
}
|
|
return c.errs[0]
|
|
}
|
|
|
|
// collectSchemaErrors is the traversal engine behind validateAgainstSchema:
|
|
// same checks, same messages, same deterministic order, but it keeps
|
|
// walking after a failure and appends every problem to the collector
|
|
// (until cap). Two deliberate exceptions to "keep walking":
|
|
// - a type mismatch stops descent into that node (its children would
|
|
// produce cascading nonsense against the wrong-typed value);
|
|
// - oneOf alternatives are probed with throwaway collectors (a failed
|
|
// alternative is not an error when a later one matches).
|
|
func collectSchemaErrors(value interface{}, schema *schemaProperty, path string, c *schemaErrorCollector) {
|
|
if schema == nil || c.full() {
|
|
return
|
|
}
|
|
if value == nil && schema.Nullable {
|
|
return
|
|
}
|
|
|
|
if schema.Type != "" {
|
|
if !matchesJSONType(value, schema.Type) {
|
|
c.add(&typeMismatchError{path: path, expected: schema.Type, got: jsType(value), enum: schema.Enum, description: schema.Description})
|
|
return // wrong container type — descending would cascade nonsense.
|
|
}
|
|
}
|
|
|
|
// Numeric bounds — only checked when value is a number (type mismatch
|
|
// already reported above). Apply to both `number` and `integer` types.
|
|
if num, ok := value.(float64); ok {
|
|
if schema.Minimum != nil && num < *schema.Minimum {
|
|
c.add(fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
|
}
|
|
if schema.Maximum != nil && num > *schema.Maximum {
|
|
c.add(fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
|
}
|
|
}
|
|
|
|
// Array length bounds — only checked when value is an array.
|
|
if arr, ok := value.([]interface{}); ok {
|
|
if schema.MinItems != nil && len(arr) < *schema.MinItems {
|
|
c.add(fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
|
}
|
|
if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
|
|
c.add(fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
|
}
|
|
}
|
|
|
|
if len(schema.Enum) > 0 {
|
|
matched := false
|
|
for _, allowed := range schema.Enum {
|
|
if jsonEqual(allowed, value) {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
msg := fmt.Sprintf("%svalue %s is not in enum %s",
|
|
pathPrefix(path), formatJSONValue(value), formatEnum(schema.Enum))
|
|
if hint := suggestEnumForError(value, schema.Enum); hint != "" {
|
|
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
|
|
}
|
|
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
|
}
|
|
}
|
|
|
|
if len(schema.OneOf) > 0 {
|
|
matched := false
|
|
for _, sub := range schema.OneOf {
|
|
probe := &schemaErrorCollector{}
|
|
collectSchemaErrors(value, sub, path, probe)
|
|
if len(probe.errs) == 0 {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
c.add(fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
|
}
|
|
}
|
|
|
|
// Object-level checks. `required` and `properties` are independent
|
|
// per JSON Schema: `required` enforces keys regardless of whether
|
|
// the schema also describes their per-key shape via `properties`.
|
|
if obj, ok := value.(map[string]interface{}); ok {
|
|
for _, key := range schema.Required {
|
|
if c.full() {
|
|
return
|
|
}
|
|
if _, present := obj[key]; !present {
|
|
msg := fmt.Sprintf("required property %q is missing at %s", key, pathOrRoot(path))
|
|
// Inline the missing field's type / one-line description / enum so
|
|
// the agent supplies a correctly-shaped value on the first retry
|
|
// instead of fetching the full schema.
|
|
if hint := schemaFieldHint(schema.Properties[key]); hint != "" {
|
|
msg += "; expected " + hint
|
|
}
|
|
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
|
}
|
|
}
|
|
if schema.Properties != nil {
|
|
keys := make([]string, 0, len(schema.Properties))
|
|
for k := range schema.Properties {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
if c.full() {
|
|
return
|
|
}
|
|
sub := schema.Properties[key]
|
|
v, present := obj[key]
|
|
if !present {
|
|
continue
|
|
}
|
|
// Case-insensitive enum tolerance: when the value matches an
|
|
// allowed enum entry except for casing, rewrite it in place to
|
|
// the canonical spelling. The schema lists enums in their
|
|
// canonical (lower-case) form, so "SUM" / "COUNTA" would
|
|
// otherwise be rejected right here before the request is even
|
|
// sent; normalizing kills the whole pivot summarize_by "SUM vs
|
|
// sum" class. Genuinely-unknown values still fail below, with
|
|
// their own did-you-mean hint.
|
|
if sub != nil && len(sub.Enum) > 0 {
|
|
if canon := suggestEnumMatch(v, sub.Enum); canon != "" {
|
|
obj[key] = canon
|
|
v = canon
|
|
}
|
|
}
|
|
child := key
|
|
if path != "" {
|
|
child = path + "." + key
|
|
}
|
|
collectSchemaErrors(v, sub, child, c)
|
|
}
|
|
}
|
|
// additionalProperties: enforce only when explicitly declared.
|
|
// Absent means lenient (matches the file header's stance). Sort
|
|
// extras so rejection order is deterministic across runs.
|
|
if schema.AdditionalProperties != nil {
|
|
extras := make([]string, 0)
|
|
for key := range obj {
|
|
if _, declared := schema.Properties[key]; declared {
|
|
continue
|
|
}
|
|
extras = append(extras, key)
|
|
}
|
|
sort.Strings(extras)
|
|
for _, key := range extras {
|
|
if c.full() {
|
|
return
|
|
}
|
|
if schema.AdditionalProperties.Strict {
|
|
msg := fmt.Sprintf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
|
|
// Inline the node's declared keys (and a did-you-mean when the
|
|
// unknown key is a near miss) so the agent renames it in one
|
|
// retry instead of a --print-schema round trip.
|
|
if legal := sortedSchemaPropertyKeys(schema.Properties); len(legal) > 0 {
|
|
if guess := suggest.Closest(key, legal, 1); len(guess) > 0 {
|
|
msg += fmt.Sprintf(` (did you mean %q?)`, guess[0])
|
|
}
|
|
msg += "; valid properties: " + formatPropertyKeyList(legal)
|
|
}
|
|
c.add(fmt.Errorf("%s", msg)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
|
|
continue
|
|
}
|
|
if schema.AdditionalProperties.Schema != nil {
|
|
child := key
|
|
if path != "" {
|
|
child = path + "." + key
|
|
}
|
|
collectSchemaErrors(obj[key], schema.AdditionalProperties.Schema, child, c)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if schema.Type == "array" && schema.Items != nil {
|
|
arr, ok := value.([]interface{})
|
|
if !ok {
|
|
return // type mismatch already reported above.
|
|
}
|
|
for i, item := range arr {
|
|
if c.full() {
|
|
return
|
|
}
|
|
child := fmt.Sprintf("%s[%d]", path, i)
|
|
collectSchemaErrors(item, schema.Items, child, c)
|
|
}
|
|
}
|
|
}
|
|
|
|
// typeMismatchError is the type-check branch of validateAgainstSchema
|
|
// as a typed error, so validateValueAgainstSchema can recognize shape
|
|
// confusion (vs. deep value errors) and inline a skeleton of the
|
|
// expected shape. Error() keeps the exact legacy wording; enum /
|
|
// description ride alongside for the deep-mismatch hintSuffix, so they
|
|
// never leak into the shallow-skeleton message.
|
|
type typeMismatchError struct {
|
|
path string
|
|
expected string
|
|
got string
|
|
enum []interface{}
|
|
description string
|
|
}
|
|
|
|
func (e *typeMismatchError) Error() string {
|
|
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
|
|
}
|
|
|
|
// hintSuffix renders the field's description / enum as a one-line tail for
|
|
// the deep type-mismatch fallback (type is already stated by Error()).
|
|
// Empty when the field declares neither.
|
|
func (e *typeMismatchError) hintSuffix() string {
|
|
var parts []string
|
|
if d := oneLineDescription(e.description); d != "" {
|
|
parts = append(parts, "description: "+d)
|
|
}
|
|
if len(e.enum) > 0 {
|
|
parts = append(parts, "one of "+formatEnum(e.enum))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// pathDepth counts how many levels below the flag root a JSON path
|
|
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
|
|
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
|
|
// segment; a leading bare key (no bracket) is one segment of its own.
|
|
func pathDepth(path string) int {
|
|
depth := strings.Count(path, "[") + strings.Count(path, ".")
|
|
if path != "" && path[0] != '[' {
|
|
depth++
|
|
}
|
|
return depth
|
|
}
|
|
|
|
// Skeleton rendering bounds: a mismatch at depth ≤ 2 is container-shape
|
|
// confusion worth a skeleton; deeper mismatches are value-level and the
|
|
// full schema pointer serves better. The skeleton itself stops after
|
|
// four levels and eight keys per object so it stays one line; a wide
|
|
// object (> skeletonWideObject keys) collapses its children to type
|
|
// placeholders so every key stays visible instead of the first branch
|
|
// eating the whole line.
|
|
const (
|
|
skeletonPathDepthLimit = 2
|
|
skeletonMaxDepth = 4
|
|
skeletonMaxKeys = 8
|
|
skeletonWideObject = 2
|
|
)
|
|
|
|
// schemaSkeleton renders a compact single-line sketch of the shape a
|
|
// schema expects, e.g. [[{"value": …, "formula": "…", …}]] for
|
|
// +cells-set --cells. Required keys come first, then alphabetical,
|
|
// capped at skeletonMaxKeys with a trailing … marker. Values render as
|
|
// their type placeholder; enum strings show the first allowed value.
|
|
func schemaSkeleton(s *schemaProperty, depth int) string {
|
|
if s == nil {
|
|
return "…"
|
|
}
|
|
if len(s.OneOf) > 0 && s.Type == "" {
|
|
return schemaSkeleton(s.OneOf[0], depth)
|
|
}
|
|
switch s.Type {
|
|
case "array":
|
|
if depth <= 0 {
|
|
return "[…]"
|
|
}
|
|
return "[" + schemaSkeleton(s.Items, depth-1) + "]"
|
|
case "object":
|
|
if depth <= 0 || len(s.Properties) == 0 {
|
|
return "{…}"
|
|
}
|
|
keys := skeletonKeys(s)
|
|
childDepth := depth - 1
|
|
if len(s.Properties) > skeletonWideObject {
|
|
childDepth = 0
|
|
}
|
|
parts := make([]string, 0, len(keys)+1)
|
|
for _, k := range keys {
|
|
parts = append(parts, fmt.Sprintf("%q: %s", k, schemaSkeleton(s.Properties[k], childDepth)))
|
|
}
|
|
if len(s.Properties) > len(keys) {
|
|
parts = append(parts, "…")
|
|
}
|
|
return "{" + strings.Join(parts, ", ") + "}"
|
|
case "string":
|
|
if len(s.Enum) > 0 {
|
|
return formatJSONValue(s.Enum[0])
|
|
}
|
|
return `"…"`
|
|
case "number", "integer":
|
|
return "0"
|
|
case "boolean":
|
|
return "false"
|
|
}
|
|
return "…"
|
|
}
|
|
|
|
// skeletonKeys picks which object keys a skeleton shows: required keys
|
|
// first (schema order), then remaining keys alphabetically, capped at
|
|
// skeletonMaxKeys.
|
|
func skeletonKeys(s *schemaProperty) []string {
|
|
keys := make([]string, 0, skeletonMaxKeys)
|
|
seen := make(map[string]struct{}, skeletonMaxKeys)
|
|
for _, k := range s.Required {
|
|
if _, ok := s.Properties[k]; !ok {
|
|
continue
|
|
}
|
|
if len(keys) == skeletonMaxKeys {
|
|
return keys
|
|
}
|
|
keys = append(keys, k)
|
|
seen[k] = struct{}{}
|
|
}
|
|
rest := make([]string, 0, len(s.Properties))
|
|
for k := range s.Properties {
|
|
if _, dup := seen[k]; !dup {
|
|
rest = append(rest, k)
|
|
}
|
|
}
|
|
sort.Strings(rest)
|
|
for _, k := range rest {
|
|
if len(keys) == skeletonMaxKeys {
|
|
break
|
|
}
|
|
keys = append(keys, k)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func matchesJSONType(value interface{}, expected string) bool {
|
|
switch expected {
|
|
case "object":
|
|
_, ok := value.(map[string]interface{})
|
|
return ok
|
|
case "array":
|
|
_, ok := value.([]interface{})
|
|
return ok
|
|
case "string":
|
|
_, ok := value.(string)
|
|
return ok
|
|
case "number":
|
|
_, ok := value.(float64)
|
|
return ok
|
|
case "integer":
|
|
f, ok := value.(float64)
|
|
return ok && f == float64(int64(f))
|
|
case "boolean":
|
|
_, ok := value.(bool)
|
|
return ok
|
|
case "null":
|
|
return value == nil
|
|
}
|
|
return true
|
|
}
|
|
|
|
func jsType(value interface{}) string {
|
|
switch value.(type) {
|
|
case nil:
|
|
return "null"
|
|
case map[string]interface{}:
|
|
return "object"
|
|
case []interface{}:
|
|
return "array"
|
|
case string:
|
|
return "string"
|
|
case float64:
|
|
return "number"
|
|
case bool:
|
|
return "boolean"
|
|
}
|
|
return fmt.Sprintf("%T", value)
|
|
}
|
|
|
|
func jsonEqual(a, b interface{}) bool {
|
|
ja, _ := json.Marshal(a)
|
|
jb, _ := json.Marshal(b)
|
|
return string(ja) == string(jb)
|
|
}
|
|
|
|
// formatJSONValue is the "what you actually passed" half of an enum
|
|
// error. Strings get JSON-quoted ("SUM"); everything else (numbers,
|
|
// booleans, null, objects, arrays) gets its JSON encoding. Marshal
|
|
// failure falls back to %v so we never panic just to format an error.
|
|
func formatJSONValue(v interface{}) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// formatEnum renders the allowed-values list for an enum error. Caps
|
|
// the visible entries at enumDisplayLimit so a 50-shortcut enum
|
|
// doesn't bury the actual error in a wall of options; the overflow
|
|
// hint tells the user how many more exist (and to consult --help /
|
|
// --print-schema for the full list).
|
|
const enumDisplayLimit = 8
|
|
|
|
func formatEnum(values []interface{}) string {
|
|
if len(values) <= enumDisplayLimit {
|
|
return "[" + joinFormatted(values) + "]"
|
|
}
|
|
shown := values[:enumDisplayLimit]
|
|
return fmt.Sprintf("[%s, … (%d more)]", joinFormatted(shown), len(values)-enumDisplayLimit)
|
|
}
|
|
|
|
func joinFormatted(values []interface{}) string {
|
|
parts := make([]string, 0, len(values))
|
|
for _, v := range values {
|
|
parts = append(parts, formatJSONValue(v))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// schemaFieldHint renders a compact one-line "type X, description: …, one of
|
|
// […]" sketch of a single field's schema, used to enrich a required-missing
|
|
// error so the agent supplies a correctly-shaped value without --print-schema.
|
|
// Empty when the field declares none of type / description / enum.
|
|
func schemaFieldHint(s *schemaProperty) string {
|
|
if s == nil {
|
|
return ""
|
|
}
|
|
var parts []string
|
|
if s.Type != "" {
|
|
parts = append(parts, fmt.Sprintf("type %q", s.Type))
|
|
}
|
|
if d := oneLineDescription(s.Description); d != "" {
|
|
parts = append(parts, "description: "+d)
|
|
}
|
|
if len(s.Enum) > 0 {
|
|
parts = append(parts, "one of "+formatEnum(s.Enum))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// sortedSchemaPropertyKeys returns the declared property names in a stable
|
|
// (sorted) order so the valid-property list in a strict unexpected-property
|
|
// error is deterministic across runs.
|
|
func sortedSchemaPropertyKeys(props map[string]*schemaProperty) []string {
|
|
keys := make([]string, 0, len(props))
|
|
for k := range props {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|
|
|
|
// propertyKeyDisplayLimit caps how many declared property names ride inline on
|
|
// a strict unexpected-property error, so a wide object doesn't bury the actual
|
|
// error under a wall of keys. Overflow is summarised as "(N more)".
|
|
const propertyKeyDisplayLimit = 15
|
|
|
|
func formatPropertyKeyList(keys []string) string {
|
|
if len(keys) <= propertyKeyDisplayLimit {
|
|
return "[" + strings.Join(keys, ", ") + "]"
|
|
}
|
|
shown := keys[:propertyKeyDisplayLimit]
|
|
return fmt.Sprintf("[%s, … (%d more)]", strings.Join(shown, ", "), len(keys)-propertyKeyDisplayLimit)
|
|
}
|
|
|
|
// descriptionMaxLen bounds an inlined field description to one reasonable line;
|
|
// schema descriptions can run several sentences, which would swamp the error.
|
|
const descriptionMaxLen = 120
|
|
|
|
// oneLineDescription collapses a (possibly multi-line) schema description into
|
|
// a single whitespace-normalised line, truncated to descriptionMaxLen runes.
|
|
// Returns "" for an empty / whitespace-only description.
|
|
func oneLineDescription(s string) string {
|
|
collapsed := strings.Join(strings.Fields(s), " ")
|
|
if collapsed == "" {
|
|
return ""
|
|
}
|
|
if r := []rune(collapsed); len(r) > descriptionMaxLen {
|
|
return string(r[:descriptionMaxLen]) + "…"
|
|
}
|
|
return collapsed
|
|
}
|
|
|
|
// suggestEnumMatch returns the canonical enum entry when the user's
|
|
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs
|
|
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical
|
|
// "middle"). Callers auto-apply the result, so it must stay restricted
|
|
// to unambiguous matches (edit-distance guesses belong in
|
|
// suggestEnumForError only). Non-string values have no vocabulary
|
|
// notion. Returns "" when no unambiguous match exists.
|
|
func suggestEnumMatch(value interface{}, values []interface{}) string {
|
|
s, ok := value.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
canon := canonicalEnumValue(s, stringEnumEntries(values))
|
|
if canon == "" || canon == s { // skip exact-equal (already would have matched).
|
|
return ""
|
|
}
|
|
return canon
|
|
}
|
|
|
|
// stringEnumEntries extracts the string members of a JSON-schema enum
|
|
// list (mixed-type enums keep only their string entries).
|
|
func stringEnumEntries(values []interface{}) []string {
|
|
out := make([]string, 0, len(values))
|
|
for _, v := range values {
|
|
if vs, ok := v.(string); ok {
|
|
out = append(out, vs)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// suggestEnumForError picks the "did you mean" candidate for an enum
|
|
// error message. Unlike suggestEnumMatch (whose result is auto-applied,
|
|
// so it must stay unambiguous), this one may also draw on edit distance
|
|
// — the suggestion is only prose, the user still has to re-issue the
|
|
// value explicitly.
|
|
func suggestEnumForError(value interface{}, values []interface{}) string {
|
|
s, ok := value.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return closestEnumValue(s, stringEnumEntries(values))
|
|
}
|
|
|
|
func pathPrefix(path string) string {
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
return path + ": "
|
|
}
|
|
|
|
func pathOrRoot(path string) string {
|
|
if path == "" {
|
|
return "(root)"
|
|
}
|
|
return path
|
|
}
|