Files
larksuite-cli/lint/errscontract/rule_build_api_error_arms.go
evandance 99e314fe0b feat(errs): typed envelope contract for auth-domain errors (#1135)
Every failure on the authentication, authorization, and configuration
path now surfaces as a typed structured error instead of an ad-hoc
envelope. Users and scripts that consume CLI output get:

  - a fixed nine-category taxonomy on the wire, each mapped to a
    stable shell exit code (authentication/authorization/config = 3,
    network = 4, internal = 5, policy = 6, confirmation = 10)
  - identity-aware detail fields (missing_scopes, requested_scopes,
    granted_scopes, console_url, log_id, retryable, hint) carried
    uniformly on the envelope
  - a single canonical policy envelope at exit 6; the legacy
    auth_error carve-out is retired
  - per-subtype canonical message + hint that preserves Lark's
    diagnostic phrasing and routes recovery to the right actor:
    app developer (app_scope_not_applied), user (missing_scope,
    token_scope_insufficient, user_unauthorized), or tenant admin
    (app_unavailable, app_disabled)
  - wrong app credentials classify as config/invalid_client whether
    surfaced by the Open API endpoint (99991543) or the tenant
    access-token mint endpoint (10003 / 10014), instead of
    collapsing to a transport error or api/unknown
  - local shortcut scope preflight emits the same
    authorization/missing_scope envelope (identity + deterministic
    missing-scope set) used by the post-call permission path, so AI
    consumers read the same structured shape from precheck and from
    server-returned permission denial
  - streaming download/upload failures keep the same network subtype
    split (timeout / TLS / DNS / transport) as the non-stream path
    instead of collapsing every cause to a generic transport failure
  - console_url is carried only on the bot-perspective
    app_scope_not_applied envelope (where the recovery action is
    "developer applies the scope at the developer console"); the
    user-perspective missing_scope envelope drops the field, since
    the only actionable user recovery is `lark-cli auth login --scope`
    and pointing an end user at a console they cannot modify is
    misleading
  - bind workflows (Hermes / OpenClaw / lark-channel) flatten dynamic
    Type tags to wire 'config' with the original module name kept
    as a metric label

All 10 typed errors are cause-bearing, nil-safe on .Error() and
.Unwrap(), and defensively clone slice setter inputs. Four lint
rules (CheckNilSafeError / CheckBuilderImmutable / CheckUnwrapSymmetry
/ CheckBuildAPIErrorArms) lock these invariants on migrated paths.
2026-05-30 19:08:41 +08:00

277 lines
8.6 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/parser"
"go/token"
"strings"
)
// canonicalCategories enumerates every taxonomy Category that BuildAPIError
// must route. Kept in sync with errs/category.go. The lint refuses to
// silently accept the omission of a new Category — when the taxonomy grows,
// either BuildAPIError gets an explicit arm or this list is updated
// consciously (drawing a reviewer's attention).
var canonicalCategories = []string{
"CategoryValidation",
"CategoryAuthentication",
"CategoryAuthorization",
"CategoryConfig",
"CategoryNetwork",
"CategoryAPI",
"CategoryPolicy",
"CategoryInternal",
"CategoryConfirmation",
}
// CheckBuildAPIErrorArms enforces that the BuildAPIError switch in
// internal/errclass/classify.go (a) covers every Category in the canonical
// taxonomy and (b) has a `default` arm that fail-closes to an InternalError
// envelope — never returns nil and never falls through to emit an empty
// Problem on the wire.
//
// Scope: only the canonical classify.go file. Other switch statements on
// Category in callers (e.g. UI rendering) intentionally remain free-form.
//
// Returns REJECT violations.
func CheckBuildAPIErrorArms(path, src string) []Violation {
if !isClassifyPath(path) {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, src, parser.ParseComments)
if err != nil {
return nil
}
var out []Violation
found := false
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Name == nil || fn.Name.Name != "BuildAPIError" || fn.Body == nil {
continue
}
found = true
sw := findCategorySwitch(fn.Body)
if sw == nil {
out = append(out, Violation{
Rule: "build_api_error_arms",
Action: ActionReject,
File: path,
Line: fset.Position(fn.Pos()).Line,
Message: "BuildAPIError has no Category switch — typed routing is the entire purpose of this function",
Suggestion: "restore the `switch meta.Category { case errs.CategoryX: ...; default: <fail-closed InternalError> }` " +
"structure",
})
continue
}
out = append(out, checkSwitchArms(path, fset, sw)...)
}
if !found {
out = append(out, Violation{
Rule: "build_api_error_arms",
Action: ActionReject,
File: path,
Line: 1,
Message: "BuildAPIError function not found in classify.go — the typed-routing entry point must exist on this file",
Suggestion: "define `func BuildAPIError(resp map[string]any, cc ClassifyContext) error` with the canonical Category switch",
})
}
return out
}
// isClassifyPath matches both repo-relative ("internal/errclass/classify.go")
// and slashed paths that contain the same suffix when scanning nested roots.
func isClassifyPath(path string) bool {
p := strings.ReplaceAll(path, "\\", "/")
return p == "internal/errclass/classify.go" || strings.HasSuffix(p, "/internal/errclass/classify.go")
}
// findCategorySwitch returns the first switch statement inside body whose
// arms reference `errs.Category*` selectors. Returns nil if no such switch
// exists. The shallow scan is sufficient — BuildAPIError contains exactly
// one taxonomy switch in production.
func findCategorySwitch(body *ast.BlockStmt) *ast.SwitchStmt {
var found *ast.SwitchStmt
ast.Inspect(body, func(n ast.Node) bool {
if found != nil {
return false
}
sw, ok := n.(*ast.SwitchStmt)
if !ok || sw.Body == nil {
return true
}
if switchMentionsCategory(sw) {
found = sw
return false
}
return true
})
return found
}
// switchMentionsCategory reports whether sw has at least one arm with an
// `errs.Category*` case expression. This is the cheap heuristic that
// identifies the canonical taxonomy switch without depending on type info.
func switchMentionsCategory(sw *ast.SwitchStmt) bool {
for _, stmt := range sw.Body.List {
cc, ok := stmt.(*ast.CaseClause)
if !ok {
continue
}
for _, expr := range cc.List {
if categoryName(expr) != "" {
return true
}
}
}
return false
}
// categoryName returns the `Category*` selector name (e.g. "CategoryAPI")
// for an `errs.Category*` expression, or "" when expr is not such a
// selector. Also accepts a bare `Category*` ident for an in-package
// switch (rare but possible).
func categoryName(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.SelectorExpr:
if x, ok := t.X.(*ast.Ident); ok && x.Name == "errs" && t.Sel != nil &&
strings.HasPrefix(t.Sel.Name, "Category") {
return t.Sel.Name
}
case *ast.Ident:
if strings.HasPrefix(t.Name, "Category") {
return t.Name
}
}
return ""
}
// checkSwitchArms validates two invariants against the located switch:
//
// 1. Every Category in canonicalCategories appears as a case expression.
// 2. The switch has a default arm whose body returns a non-nil expression
// (i.e. fails closed to InternalError).
func checkSwitchArms(path string, fset *token.FileSet, sw *ast.SwitchStmt) []Violation {
covered := map[string]struct{}{}
var defaultArm *ast.CaseClause
for _, stmt := range sw.Body.List {
cc, ok := stmt.(*ast.CaseClause)
if !ok {
continue
}
if cc.List == nil {
defaultArm = cc
continue
}
for _, expr := range cc.List {
if name := categoryName(expr); name != "" {
covered[name] = struct{}{}
}
}
}
var out []Violation
for _, cat := range canonicalCategories {
if _, ok := covered[cat]; ok {
continue
}
out = append(out, Violation{
Rule: "build_api_error_arms",
Action: ActionReject,
File: path,
Line: fset.Position(sw.Pos()).Line,
Message: "BuildAPIError switch is missing explicit arm for errs." + cat,
Suggestion: "add a case clause for errs." + cat + " that routes to the matching typed *Error; " +
"the canonical taxonomy is fixed in errs/category.go and every Category must be handled",
})
}
if defaultArm == nil {
out = append(out, Violation{
Rule: "build_api_error_arms",
Action: ActionReject,
File: path,
Line: fset.Position(sw.Pos()).Line,
Message: "BuildAPIError switch has no default arm — unknown Category would fall through and emit an empty Problem",
Suggestion: "add `default:` that fail-closes to `&errs.InternalError{Problem: ...SubtypeSDKError...}` " +
"so unrecognised Category values cannot produce a wire-invalid envelope",
})
} else if !defaultReturnsInternalError(defaultArm) {
out = append(out, Violation{
Rule: "build_api_error_arms",
Action: ActionReject,
File: path,
Line: fset.Position(defaultArm.Pos()).Line,
Message: "BuildAPIError default arm returns nil or has no return — must fail closed to InternalError",
Suggestion: "return `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` " +
"so an unrecognised Category never silently drops the failure",
})
}
return out
}
// defaultReturnsInternalError checks the default arm's body has a return
// statement whose first result is *errs.InternalError — either constructed
// via `&errs.InternalError{...}` composite literal or `errs.NewInternalError(...)`
// constructor. Accepts both selector form (`errs.InternalError`) and bare
// identifier (`InternalError`) so unit-test fixtures with a local stub
// package match. The BuildAPIError default arm MUST fail closed to
// InternalError; other typed errors (APIError, etc.) silently drop the
// "unknown Category" signal and are rejected by this rule.
func defaultReturnsInternalError(cc *ast.CaseClause) bool {
for _, stmt := range cc.Body {
ret, ok := stmt.(*ast.ReturnStmt)
if !ok || len(ret.Results) == 0 {
continue
}
if isInternalErrorReturn(ret.Results[0]) {
return true
}
}
return false
}
func isInternalErrorReturn(expr ast.Expr) bool {
switch e := expr.(type) {
case *ast.UnaryExpr:
// &errs.InternalError{...} or &InternalError{...}
if e.Op != token.AND {
return false
}
if cl, ok := e.X.(*ast.CompositeLit); ok {
return isInternalErrorType(cl.Type)
}
case *ast.CompositeLit:
// errs.InternalError{...} or InternalError{...} (value, rare for errors)
return isInternalErrorType(e.Type)
case *ast.CallExpr:
// errs.NewInternalError(...) or NewInternalError(...)
return isNewInternalErrorCall(e.Fun)
}
return false
}
func isInternalErrorType(t ast.Expr) bool {
switch x := t.(type) {
case *ast.SelectorExpr:
return x.Sel.Name == "InternalError"
case *ast.Ident:
return x.Name == "InternalError"
}
return false
}
func isNewInternalErrorCall(fn ast.Expr) bool {
switch x := fn.(type) {
case *ast.SelectorExpr:
return x.Sel.Name == "NewInternalError"
case *ast.Ident:
return x.Name == "NewInternalError"
}
return false
}