mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
237 lines
7.6 KiB
Go
237 lines
7.6 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package schema
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/larksuite/cli/internal/core"
|
|
)
|
|
|
|
var validJSONSchemaTypes = map[string]bool{
|
|
"string": true,
|
|
"integer": true,
|
|
"number": true,
|
|
"boolean": true,
|
|
"array": true,
|
|
"object": true,
|
|
}
|
|
|
|
var validAccessTokens = map[string]bool{
|
|
"user": true,
|
|
"bot": true,
|
|
}
|
|
|
|
// lintEnvelope runs L1-L3 checks and returns a list of errors. Empty slice
|
|
// means the envelope is compliant.
|
|
func lintEnvelope(env Envelope) []error {
|
|
var errs []error
|
|
|
|
// ---- L1: structural ----
|
|
if env.Name == "" {
|
|
errs = append(errs, errors.New("L1: name must not be empty"))
|
|
}
|
|
if env.InputSchema == nil {
|
|
errs = append(errs, errors.New("L1: inputSchema must not be nil"))
|
|
} else {
|
|
if env.InputSchema.Type != "object" {
|
|
errs = append(errs, fmt.Errorf("L1: inputSchema.type = %q, want \"object\"", env.InputSchema.Type))
|
|
}
|
|
if env.InputSchema.Properties == nil {
|
|
errs = append(errs, errors.New("L1: inputSchema.properties must not be nil"))
|
|
}
|
|
}
|
|
if env.OutputSchema == nil {
|
|
errs = append(errs, errors.New("L1: outputSchema must not be nil"))
|
|
} else {
|
|
if env.OutputSchema.Type != "object" {
|
|
errs = append(errs, fmt.Errorf("L1: outputSchema.type = %q, want \"object\"", env.OutputSchema.Type))
|
|
}
|
|
}
|
|
if env.Meta == nil {
|
|
errs = append(errs, errors.New("L1: _meta must not be nil"))
|
|
// Cannot continue meta-dependent checks
|
|
return errs
|
|
}
|
|
if env.Meta.EnvelopeVersion != "1.0" {
|
|
errs = append(errs, fmt.Errorf("L1: _meta.envelope_version = %q, want \"1.0\"", env.Meta.EnvelopeVersion))
|
|
}
|
|
|
|
// L1: validate every Property type recursively
|
|
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
|
validatePropertyTypes(env.InputSchema.Properties, &errs)
|
|
}
|
|
if env.OutputSchema != nil && env.OutputSchema.Properties != nil {
|
|
validatePropertyTypes(env.OutputSchema.Properties, &errs)
|
|
}
|
|
|
|
// ---- L2: type-level consistency ----
|
|
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
|
// Walk the whole property tree so format/min-max checks reach leaf
|
|
// fields nested under the params/data wrapper.
|
|
walkForL2(env.InputSchema.Properties, &errs)
|
|
// Top-level required keys must exist in top-level properties.
|
|
for _, r := range env.InputSchema.Required {
|
|
if _, ok := env.InputSchema.Properties.Map[r]; !ok {
|
|
errs = append(errs, fmt.Errorf("L2: required key %q not found in properties", r))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- L3: cross-field self-consistency ----
|
|
dangerExpected := env.Meta.Risk == core.RiskWrite || env.Meta.Risk == core.RiskHighRiskWrite
|
|
if env.Meta.Danger != dangerExpected {
|
|
errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk))
|
|
}
|
|
|
|
// `yes` lives at inputSchema.properties.yes (sibling of params/data),
|
|
// injected only for risk == RiskHighRiskWrite.
|
|
hasYes := false
|
|
if env.InputSchema != nil && env.InputSchema.Properties != nil {
|
|
_, hasYes = env.InputSchema.Properties.Map["yes"]
|
|
}
|
|
wantYes := env.Meta.Risk == core.RiskHighRiskWrite
|
|
if hasYes != wantYes {
|
|
errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk))
|
|
}
|
|
|
|
if len(env.Meta.AccessTokens) == 0 {
|
|
errs = append(errs, errors.New("L3: _meta.access_tokens must not be empty"))
|
|
}
|
|
for _, t := range env.Meta.AccessTokens {
|
|
if !validAccessTokens[t] {
|
|
errs = append(errs, fmt.Errorf("L3: _meta.access_tokens contains invalid value %q (allowed: user, bot)", t))
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// walkForL2 recursively applies per-field L2 checks (format:binary on
|
|
// non-string; minimum>=maximum) plus the sub-object required-exists invariant.
|
|
// Required only matters on object-typed Properties (e.g. the params / data
|
|
// wrappers); leaf scalars ignore it.
|
|
func walkForL2(props *OrderedProps, errs *[]error) {
|
|
if props == nil {
|
|
return
|
|
}
|
|
for _, k := range props.Order {
|
|
p := props.Map[k]
|
|
if p.Format == "binary" && p.Type != "string" {
|
|
*errs = append(*errs, fmt.Errorf("L2: field %q has format: binary but type = %q (want string)", k, p.Type))
|
|
}
|
|
if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum {
|
|
*errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum))
|
|
}
|
|
if n := len(p.EnumDescriptions); n > 0 && n != len(p.Enum) {
|
|
*errs = append(*errs, fmt.Errorf("L2: field %q enumDescriptions length (%d) != enum length (%d)", k, n, len(p.Enum)))
|
|
}
|
|
if len(p.Required) > 0 && p.Properties != nil {
|
|
for _, r := range p.Required {
|
|
if _, ok := p.Properties.Map[r]; !ok {
|
|
*errs = append(*errs, fmt.Errorf("L2: required key %q in %q not found in its properties", r, k))
|
|
}
|
|
}
|
|
}
|
|
if p.Properties != nil {
|
|
walkForL2(p.Properties, errs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// validatePropertyTypes walks an OrderedProps tree and asserts:
|
|
// - every Property.Type is in validJSONSchemaTypes (or empty for nested objects with only properties)
|
|
// - array Properties have Items
|
|
//
|
|
// Errors are appended to *errs.
|
|
func validatePropertyTypes(props *OrderedProps, errs *[]error) {
|
|
if props == nil {
|
|
return
|
|
}
|
|
for _, k := range props.Order {
|
|
p := props.Map[k]
|
|
if p.Type != "" && !validJSONSchemaTypes[p.Type] {
|
|
*errs = append(*errs, fmt.Errorf("L1: property %q has invalid type %q", k, p.Type))
|
|
}
|
|
if p.Type == "array" && p.Items == nil {
|
|
*errs = append(*errs, fmt.Errorf("L1: array property %q missing items", k))
|
|
}
|
|
if p.Properties != nil {
|
|
validatePropertyTypes(p.Properties, errs)
|
|
}
|
|
// Validate the array-element schema itself, not only its child
|
|
// properties — a primitive element with an invalid type (e.g.
|
|
// `items.type = "list"`) would otherwise slip past lint.
|
|
if p.Items != nil {
|
|
validateItemSchema(k, p.Items, errs)
|
|
}
|
|
}
|
|
}
|
|
|
|
// validateItemSchema checks a single array element schema for invalid types,
|
|
// then recurses into any further nested properties/items.
|
|
func validateItemSchema(parentKey string, item *Property, errs *[]error) {
|
|
if item.Type != "" && !validJSONSchemaTypes[item.Type] {
|
|
*errs = append(*errs, fmt.Errorf("L1: array property %q items has invalid type %q", parentKey, item.Type))
|
|
}
|
|
if item.Type == "array" && item.Items == nil {
|
|
*errs = append(*errs, fmt.Errorf("L1: array property %q items (nested array) missing items", parentKey))
|
|
}
|
|
if item.Properties != nil {
|
|
validatePropertyTypes(item.Properties, errs)
|
|
}
|
|
if item.Items != nil {
|
|
validateItemSchema(parentKey, item.Items, errs)
|
|
}
|
|
}
|
|
|
|
// coverageBaseline is the per-metric warn threshold for L4 coverage checks.
|
|
// If the measured rate drops below the baseline, t.Logf emits a warning but
|
|
// does NOT fail the test. Adjust these constants upward as meta_data quality
|
|
// improves over time.
|
|
var coverageBaseline = map[string]float64{
|
|
"description": 0.99,
|
|
"scopes": 1.00,
|
|
"doc_url": 0.98,
|
|
"risk": 0.96,
|
|
}
|
|
|
|
// measureCoverage returns the non-empty rate for each tracked metric.
|
|
func measureCoverage(envs []Envelope) map[string]float64 {
|
|
if len(envs) == 0 {
|
|
return map[string]float64{
|
|
"description": 0,
|
|
"scopes": 0,
|
|
"doc_url": 0,
|
|
"risk": 0,
|
|
}
|
|
}
|
|
total := float64(len(envs))
|
|
var descNonEmpty, scopesNonEmpty, docURLNonEmpty, riskNonEmpty float64
|
|
for _, e := range envs {
|
|
if e.Description != "" {
|
|
descNonEmpty++
|
|
}
|
|
if e.Meta == nil {
|
|
continue
|
|
}
|
|
if len(e.Meta.Scopes) > 0 {
|
|
scopesNonEmpty++
|
|
}
|
|
if e.Meta.DocURL != "" {
|
|
docURLNonEmpty++
|
|
}
|
|
if e.Meta.Risk != "" {
|
|
riskNonEmpty++
|
|
}
|
|
}
|
|
return map[string]float64{
|
|
"description": descNonEmpty / total,
|
|
"scopes": scopesNonEmpty / total,
|
|
"doc_url": docURLNonEmpty / total,
|
|
"risk": riskNonEmpty / total,
|
|
}
|
|
}
|