Files
larksuite-cli/lint/errscontract/typecheck.go
evandance fe72e41fb2 feat(errs): add structured CLI error contract (#984)
Introduce a typed error contract framework for lark-cli so in-process
Go callers can branch via errors.As(&errs.XxxError{}) and shell scripts,
AI agents, and protocol adapters can branch on stable JSON type/subtype
fields instead of regex-parsing free-form messages.

Adds:
- Canonical taxonomy under errs/ (9 categories + typed Error structs
  embedding a shared Problem, RFC 7807-aligned)
- Centralized Lark code metadata + identity-aware BuildAPIError dispatch
- Typed JSON envelope writer alongside the legacy envelope writer
- MCP / OAuth (RFC 6750 Bearer) projection adapters
- Five CI lint guards preventing ad-hoc taxonomy drift

Backward compatibility: legacy *output.ExitError producers (ErrAPI,
ErrWithHint, Errorf, ErrBare) and business shortcuts that use them
continue to render the legacy envelope unchanged. SecurityPolicyError
wire format and exit code are preserved via a carve-out; taxonomy
migration is deferred to PR 2. Domain-specific business migration is
staged across PR 3+.

Framework-direct paths now return typed *errs.*Error: ErrAuth /
ErrValidation / ErrNetwork emit category literals on the wire
(authentication / validation / network), *core.ConfigError is promoted
at the cmd/root boundary with exit code aligned from 2 to 3, and Lark
API permission denials classified by BuildAPIError exit 3.

At the SDK boundary, WrapDoAPIError preserves any already-classified
error (legacy *output.ExitError or typed *errs.*) so output.ErrAuth
from missing credentials surfaces with the auth category and exit 3
intact instead of being downgraded to a network error. Policy responses
classified by BuildAPIError (codes 21000 / 21001) extract challenge_url
and the canonical hint from the response body, matching what the
auth transport already surfaces at the HTTP layer; non-https
challenge URLs are dropped.

First PR in the feat/error-contract-* series.
2026-05-26 11:42:33 +08:00

191 lines
6.7 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"go/ast"
"go/types"
"path/filepath"
"strings"
"golang.org/x/tools/go/packages"
)
// errsPkgPath is the canonical import path of the typed-errors package.
// CheckDeclaredSubtype's typed-resolution pass verifies that a `pkg.SubtypeXxx` selector's
// resolved object belongs to this exact package — selector-name matching
// alone would have falsely accepted an identically-named constant from a
// foreign package.
const errsPkgPath = "github.com/larksuite/cli/errs"
// TypedScope captures the workspace-wide type information used by CheckDeclaredSubtype's
// typed-resolution pass. The zero value is a no-op (typed pass disabled);
// LoadTypedScope populates it.
//
// Once populated:
// - typedFiles maps an absolute Go file path to the *types.Info of its
// package. The walker uses it to resolve selector / ident references on
// a per-file basis: Info.Uses[ident] yields the *types.Object pointed
// at by that identifier, including the originating package.
// - errsSubtypeConsts holds the typed Subtype constants declared in the
// errs package. A resolved object is a "real" Subtype only when it
// appears in this set.
type TypedScope struct {
typedFiles map[string]*types.Info
errsSubtypeConsts map[string]*types.Const
}
// Enabled reports whether the typed-resolution pass can answer questions
// about errs.Subtype references. It requires both:
//
// - typedFiles non-empty (go/packages.Load produced usable type info);
// - errsSubtypeConsts non-empty (the canonical errs.Subtype const set
// was actually discovered).
//
// Requiring both avoids the half-loaded failure mode where typed-file
// indexing succeeded but the errs package was not visited — every
// resolution attempt would then claim "foreign const" and over-reject.
// Callers fall back to AST-only resolution when Enabled returns false.
func (s *TypedScope) Enabled() bool {
if s == nil {
return false
}
return len(s.typedFiles) > 0 && len(s.errsSubtypeConsts) > 0
}
// LookupFileInfo returns the per-package types.Info covering the given Go
// file (path matching the absolute path used during the load). Callers use
// it to resolve *ast.Ident → *types.Object via Info.Uses.
func (s *TypedScope) LookupFileInfo(absPath string) (*types.Info, bool) {
if s == nil {
return nil, false
}
info, ok := s.typedFiles[filepath.Clean(absPath)]
return info, ok
}
// LoadTypedScope loads the workspace rooted at root with full type
// information and returns a scope ready for CheckDeclaredSubtype typed resolution. A
// non-nil error reports an unrecoverable failure (the loader could not
// even start); a successful return with Enabled() == false indicates the
// loader ran but produced no usable type info (e.g. the errs package was
// missing) — in which case the caller should fall back silently to the
// AST-only path.
func LoadTypedScope(root string) (*TypedScope, error) {
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedFiles |
packages.NeedCompiledGoFiles |
packages.NeedImports |
packages.NeedDeps |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo,
Dir: root,
Tests: false,
}
pkgs, err := packages.Load(cfg, "./...")
if err != nil {
return nil, err
}
scope := &TypedScope{
typedFiles: map[string]*types.Info{},
errsSubtypeConsts: map[string]*types.Const{},
}
packages.Visit(pkgs, nil, func(p *packages.Package) {
if p == nil || p.TypesInfo == nil {
return
}
// Index file → TypesInfo for the walker.
for _, f := range p.CompiledGoFiles {
scope.typedFiles[filepath.Clean(f)] = p.TypesInfo
}
// Capture declared Subtype constants from the canonical errs package
// so CheckDeclaredSubtype can reject selectors that resolve to a foreign-package
// const sharing the same name.
if p.PkgPath == errsPkgPath && p.Types != nil {
collectSubtypeConsts(p.Types, scope.errsSubtypeConsts)
}
})
return scope, nil
}
// collectSubtypeConsts scans a *types.Package for exported constants of
// type errs.Subtype whose name starts with "Subtype" and records them by
// name. The "Subtype" name prefix is enforced so the helper aligns with
// the CheckDeclaredSubtype AST pass and avoids matching the underlying `Subtype` type
// definition itself.
func collectSubtypeConsts(pkg *types.Package, into map[string]*types.Const) {
if pkg == nil || pkg.Scope() == nil {
return
}
for _, name := range pkg.Scope().Names() {
if !strings.HasPrefix(name, "Subtype") || name == "Subtype" {
continue
}
obj := pkg.Scope().Lookup(name)
c, ok := obj.(*types.Const)
if !ok {
continue
}
// Verify the constant's type is errs.Subtype (not e.g. a foreign
// "Subtype"-named string alias re-exported from this package).
named, ok := c.Type().(*types.Named)
if !ok {
continue
}
if named.Obj() == nil || named.Obj().Name() != "Subtype" ||
named.Obj().Pkg() == nil || named.Obj().Pkg().Path() != errsPkgPath {
continue
}
into[name] = c
}
}
// ResolveSubtypeIdent inspects the identifier used as the value of a
// `Subtype:` composite-literal field and reports the typed-scope verdict
// via the (resolved, ok) tuple:
//
// - (true, true): the identifier is a declared errs.Subtype constant.
// The AST pass may skip its nameset check for this site.
// - (false, true): definitive rejection — the identifier resolved to a
// constant in a non-errs package, or to a non-Subtype constant inside
// errs. Caller MUST NOT fall back to AST resolution; CheckDeclaredSubtype should
// reject this site.
// - (false, false): typed scope cannot decide (scope disabled, no file
// info, sel==nil, no type info for the identifier, or the resolved
// object is not a constant). Caller defers to AST-only resolution.
func (s *TypedScope) ResolveSubtypeIdent(absPath string, sel *ast.Ident) (resolved, ok bool) {
if !s.Enabled() {
return false, false
}
info, found := s.LookupFileInfo(absPath)
if !found || info == nil || sel == nil {
return false, false
}
obj, found := info.Uses[sel]
if !found || obj == nil {
// No type info for this identifier — caller falls back to AST.
return false, false
}
c, isConst := obj.(*types.Const)
if !isConst {
return false, false
}
if c.Pkg() == nil || c.Pkg().Path() != errsPkgPath {
// Foreign-package constant assigned to a Subtype: slot. Reject —
// the caller routes ALL selectors through this path regardless of
// name shape, so this branch fires for both `foreign.SubtypeFoo`
// and `foreign.MyKind`.
return false, true
}
if _, declared := s.errsSubtypeConsts[c.Name()]; !declared {
// In the errs package but not a Subtype const (defense-in-depth).
return false, true
}
return true, true
}