mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
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.
191 lines
6.7 KiB
Go
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
|
|
}
|