Files
larksuite-cli/shortcuts/sheets/flag_schema_validate.go
xiongyuanwen-byted a814c8cb43 fix(sheets): satisfy errs-no-bare-wrap forbidigo and errorlint rules from main
main introduced the errs-no-bare-wrap forbidigo rule and errorlint
coverage that flag 27 issues in existing sheets code after the merge:

- Replace direct *errs.ValidationError type assertions with errors.As
  in sheetsInputStatError and validateSheetMediaUploadFile so wrapped
  errors still match (errorlint).
- Type the embedded flag-schemas.json parse failure as an InternalError
  with cause; it reaches the user directly via --print-schema.
- Annotate genuine intermediate errors (recursive schema validator,
  batch sub-op raw type checks, A1 range/position parsers) with
  //nolint:forbidigo; every caller wraps them into typed flag
  validation errors.
2026-07-03 11:39:03 +08:00

510 lines
18 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
// ─── 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)
if vErr := validateAgainstSchema(value, &schema, ""); vErr != nil {
// 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.
// Point them straight at --print-schema, which dumps the exact JSON
// Schema for this (command, flag) pair. The hint is always actionable:
// reaching this branch means entry[name] resolved a schema from the
// embedded index, and --print-schema reads that same index, so the
// suggested command is guaranteed to print it.
return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, vErr.Error(), command, name).WithCause(vErr)
}
return nil
}
// 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. Unknown keys (description, …) are dropped —
// they're documentation.
//
// 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"`
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
}
// validateAgainstSchema recursively checks `value` against `schema`,
// prefixing any failure with the JSON path navigated so far.
func validateAgainstSchema(value interface{}, schema *schemaProperty, path string) error {
if schema == nil {
return nil // defensive — current callers always pass &schema, but
// keeps validator safe for future programmatic construction.
}
if value == nil && schema.Nullable {
return nil
}
if schema.Type != "" {
if !matchesJSONType(value, schema.Type) {
return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
}
// 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 {
return 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 {
return 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 {
return 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 {
return 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 := suggestEnumMatch(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint)
}
return 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 {
if validateAgainstSchema(value, sub, path) == nil {
matched = true
break
}
}
if !matched {
return 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 _, present := obj[key]; !present {
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //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 {
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
}
if err := validateAgainstSchema(v, sub, child); err != nil {
return err
}
}
}
// additionalProperties: enforce only when explicitly declared.
// Absent means lenient (matches the file header's stance). Sort
// extras so the first rejection 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 schema.AdditionalProperties.Strict {
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint
}
if schema.AdditionalProperties.Schema != nil {
child := key
if path != "" {
child = path + "." + key
}
if err := validateAgainstSchema(obj[key], schema.AdditionalProperties.Schema, child); err != nil {
return err
}
}
}
}
}
if schema.Type == "array" && schema.Items != nil {
arr, ok := value.([]interface{})
if !ok {
return nil // type mismatch already reported above.
}
for i, item := range arr {
child := fmt.Sprintf("%s[%d]", path, i)
if err := validateAgainstSchema(item, schema.Items, child); err != nil {
return err
}
}
}
return nil
}
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, ", ")
}
// suggestEnumMatch returns a "did you mean" candidate when the user's
// value differs from an allowed enum entry only in casing — the most
// common real-world mistake ("SUM" vs "sum", "True" vs "true"). The
// match is restricted to strings; non-string enums (numbers, etc.)
// don't have a casing notion. Returns "" when no near-miss exists.
func suggestEnumMatch(value interface{}, values []interface{}) string {
s, ok := value.(string)
if !ok {
return ""
}
lower := strings.ToLower(s)
for _, v := range values {
if vs, ok := v.(string); ok && strings.ToLower(vs) == lower {
if vs != s { // skip exact-equal (already would have matched).
return vs
}
}
}
return ""
}
func pathPrefix(path string) string {
if path == "" {
return ""
}
return path + ": "
}
func pathOrRoot(path string) string {
if path == "" {
return "(root)"
}
return path
}