mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +08:00
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.
510 lines
18 KiB
Go
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
|
|
}
|