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.
386 lines
13 KiB
Go
386 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package errscontract
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ScanRepo is the production entry point for the lintcheck CLI. It walks
|
|
// the repo rooted at root and emits violations covering all four checks.
|
|
//
|
|
// root should be the repo root (the directory containing go.mod). The CheckDeclaredSubtype
|
|
// allowlist (values + declared names) is derived from every errs/subtypes*.go
|
|
// file; if no subtypes file is found, CheckDeclaredSubtype is silently skipped (CheckAdHocSubtype
|
|
// still runs).
|
|
//
|
|
// Returns the violations sorted by File/Line for stable diff against expected
|
|
// output in tests.
|
|
func ScanRepo(root string) ([]Violation, error) {
|
|
allowlist, nameset, err := LoadSubtypeAllowlists(filepath.Join(root, "errs"))
|
|
if err != nil {
|
|
// "Subtype allowlist file missing" → skip CheckDeclaredSubtype; CheckAdHocSubtype still
|
|
// catches ad_hoc_*. Any other error (permission, malformed source)
|
|
// must propagate — otherwise a real taxonomy regression silently
|
|
// disables CheckDeclaredSubtype in CI.
|
|
if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("load subtype allowlists: %w", err)
|
|
}
|
|
allowlist = nil
|
|
nameset = nil
|
|
}
|
|
|
|
var all []Violation
|
|
|
|
// CheckProblemEmbed: errs/ contract parity (types ↔ predicates ↔ tests ↔ docs).
|
|
if contractViols, err := CheckErrsContract(root); err == nil {
|
|
all = append(all, contractViols...)
|
|
} else if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("rule B: %w", err)
|
|
}
|
|
|
|
// CheckDeclaredSubtype typed resolution: load the workspace's type info once so we
|
|
// can verify Subtype selectors resolve into the canonical errs package.
|
|
// A loader failure or empty result falls back to the AST-only pass —
|
|
// the unit-test API path that ScanRepo shares with
|
|
// CheckDeclaredSubtypeWithNames already enforces nameset matching.
|
|
// When the fallback is taken on a workspace that LOOKS like a Go repo
|
|
// (has a go.mod), we emit a single advisory diagnostic so reviewers
|
|
// know CheckDeclaredSubtype ran in a less-strict mode this run. ActionWarning is
|
|
// print-only per Action semantics; it does not fail CI.
|
|
typedScope, typedErr := LoadTypedScope(root)
|
|
if typedErr != nil {
|
|
typedScope = nil
|
|
}
|
|
if !typedScope.Enabled() && hasGoMod(root) {
|
|
all = append(all, Violation{
|
|
Rule: "declared_subtype",
|
|
Action: ActionWarning,
|
|
File: "lint",
|
|
Line: 0,
|
|
Message: "CheckDeclaredSubtype typed resolution unavailable; falling back to AST name matching. " +
|
|
"Workspace was loadable as a Go repo, but errs.Subtype constants could not be resolved via go/types. " +
|
|
"CheckDeclaredSubtype will be less strict on Subtype: selectors this run.",
|
|
Suggestion: "ensure errs/subtypes*.go compile and contain typed Subtype consts; " +
|
|
"re-run with `go run -C lint . ..` after verifying.",
|
|
})
|
|
}
|
|
|
|
// Walk source tree and apply Rules C/D/E to each .go file.
|
|
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
// Skip well-known noise directories.
|
|
name := d.Name()
|
|
if name == ".git" || name == "node_modules" || name == "vendor" ||
|
|
name == "tests_e2e" || name == "skill-template" || name == "skills" ||
|
|
name == "docs" || name == "specs" {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(path, ".go") {
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(path, "_test.go") {
|
|
// CheckNoRegistrar / D / E do not fire in test files: fixtures may legitimately
|
|
// exercise edge values, and CheckNoRegistrar's scope is production code only.
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(root, path)
|
|
src, err := os.ReadFile(path) //nolint:gosec // CLI tool; root is operator-provided.
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
all = append(all, CheckNoRegistrar(rel, string(src))...)
|
|
all = append(all, CheckAdHocSubtype(rel, string(src))...)
|
|
all = append(all, CheckTypedErrorCompleteness(rel, string(src))...)
|
|
if allowlist != nil && !isErrsScope(rel) {
|
|
// CheckDeclaredSubtype does not fire inside the errs/ package itself — that
|
|
// package defines the Subtype type and its constructors take
|
|
// Subtype as a parameter, which would otherwise emit a stream
|
|
// of dynamic-identifier WARNINGs.
|
|
abs, _ := filepath.Abs(path)
|
|
all = append(all, checkDeclaredSubtypeWithTypedScope(rel, abs, string(src), allowlist, nameset, typedScope)...)
|
|
}
|
|
return nil
|
|
})
|
|
if walkErr != nil {
|
|
return nil, walkErr
|
|
}
|
|
|
|
sort.SliceStable(all, func(i, j int) bool {
|
|
if all[i].File != all[j].File {
|
|
return all[i].File < all[j].File
|
|
}
|
|
return all[i].Line < all[j].Line
|
|
})
|
|
return all, nil
|
|
}
|
|
|
|
// hasGoMod reports whether the given directory contains a go.mod file at
|
|
// its root. Used to scope the typed-resolution advisory to repos that look
|
|
// like Go workspaces; unit-test fixtures without go.mod stay silent.
|
|
func hasGoMod(root string) bool {
|
|
_, err := os.Stat(filepath.Join(root, "go.mod"))
|
|
return err == nil
|
|
}
|
|
|
|
// isErrsScope reports whether a path is inside the errs/ package (including
|
|
// any subpackage). Used to scope-out CheckDeclaredSubtype from the package
|
|
// that owns the Subtype type itself.
|
|
func isErrsScope(path string) bool {
|
|
p := strings.ReplaceAll(path, "\\", "/")
|
|
return strings.HasPrefix(p, "errs/") || strings.Contains(p, "/errs/")
|
|
}
|
|
|
|
// LoadSubtypeAllowlist parses errs/subtypes.go and returns the set of declared
|
|
// Subtype constant VALUES (not names). Used by CheckDeclaredSubtype.
|
|
//
|
|
// Deprecated: prefer LoadSubtypeAllowlists, which also captures the constant
|
|
// names across every errs/subtypes*.go file. Retained for the unit-test entry
|
|
// point that targets a single fixture file.
|
|
func LoadSubtypeAllowlist(subtypesGo string) (map[string]struct{}, error) {
|
|
values, _, err := loadSubtypeAllowlistFile(subtypesGo)
|
|
return values, err
|
|
}
|
|
|
|
// LoadSubtypeAllowlists scans every errs/subtypes*.go file under the given
|
|
// directory and returns (declared VALUES, declared NAMES). The name set lets
|
|
// CheckDeclaredSubtype reject typo'd selectors like `errs.SubtypeBogus` that satisfy the
|
|
// "Subtype*" prefix but reference no actual constant. Returns the os.Stat
|
|
// error if the directory does not exist.
|
|
func LoadSubtypeAllowlists(errsDir string) (values, names map[string]struct{}, err error) {
|
|
if _, statErr := os.Stat(errsDir); statErr != nil {
|
|
return nil, nil, statErr
|
|
}
|
|
entries, readErr := os.ReadDir(errsDir)
|
|
if readErr != nil {
|
|
return nil, nil, readErr
|
|
}
|
|
values = make(map[string]struct{})
|
|
names = make(map[string]struct{})
|
|
found := 0
|
|
for _, e := range entries {
|
|
if e.IsDir() {
|
|
continue
|
|
}
|
|
name := e.Name()
|
|
if !strings.HasPrefix(name, "subtypes") || !strings.HasSuffix(name, ".go") ||
|
|
strings.HasSuffix(name, "_test.go") {
|
|
continue
|
|
}
|
|
full := filepath.Join(errsDir, name)
|
|
v, n, perr := loadSubtypeAllowlistFile(full)
|
|
if perr != nil {
|
|
return nil, nil, perr
|
|
}
|
|
for k := range v {
|
|
values[k] = struct{}{}
|
|
}
|
|
for k := range n {
|
|
names[k] = struct{}{}
|
|
}
|
|
found++
|
|
}
|
|
if found == 0 {
|
|
// Treat absence like a missing file — caller silently skips CheckDeclaredSubtype
|
|
// via os.IsNotExist on the wrapped sentinel.
|
|
return nil, nil, fmt.Errorf("%w: no subtypes*.go found under %s", os.ErrNotExist, errsDir)
|
|
}
|
|
return values, names, nil
|
|
}
|
|
|
|
func loadSubtypeAllowlistFile(subtypesGo string) (values, names map[string]struct{}, err error) {
|
|
src, err := os.ReadFile(subtypesGo) //nolint:gosec // operator-provided path.
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
fset := token.NewFileSet()
|
|
file, err := parser.ParseFile(fset, subtypesGo, src, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("parse %s: %w", subtypesGo, err)
|
|
}
|
|
values = make(map[string]struct{})
|
|
names = make(map[string]struct{})
|
|
for _, decl := range file.Decls {
|
|
gd, ok := decl.(*ast.GenDecl)
|
|
if !ok || gd.Tok != token.CONST {
|
|
continue
|
|
}
|
|
for _, spec := range gd.Specs {
|
|
vs, ok := spec.(*ast.ValueSpec)
|
|
if !ok {
|
|
continue
|
|
}
|
|
// We only care about const blocks whose type is Subtype (the type
|
|
// declared in this same file). Untyped/iota constants are ignored.
|
|
if !isSubtypeTypeRef(vs.Type) {
|
|
continue
|
|
}
|
|
for _, n := range vs.Names {
|
|
if n.Name != "_" {
|
|
names[n.Name] = struct{}{}
|
|
}
|
|
}
|
|
for _, v := range vs.Values {
|
|
lit, ok := v.(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
continue
|
|
}
|
|
values[unquoteSimple(lit.Value)] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
return values, names, nil
|
|
}
|
|
|
|
func isSubtypeTypeRef(expr ast.Expr) bool {
|
|
switch t := expr.(type) {
|
|
case *ast.Ident:
|
|
return t.Name == "Subtype"
|
|
case *ast.SelectorExpr:
|
|
return t.Sel != nil && t.Sel.Name == "Subtype"
|
|
}
|
|
return false
|
|
}
|
|
|
|
// CheckErrsContract enforces CheckProblemEmbed at the directory level. It collects all
|
|
// exported `*Error` types defined in errs/, then verifies:
|
|
//
|
|
// 1. each type embeds Problem (delegated to CheckProblemEmbed per file);
|
|
// 2. each non-whitelisted type has a matching IsXxx predicate in errs/;
|
|
// 3. each type is mentioned in at least one errs/*_test.go file.
|
|
//
|
|
// Missing predicates and missing tests each emit one diagnostic per type.
|
|
//
|
|
// Also walks internal/errclass/codemeta*.go for code-meta parity; absence of
|
|
// the directory is tolerated (older repo layouts).
|
|
func CheckErrsContract(root string) ([]Violation, error) {
|
|
errsDir := filepath.Join(root, "errs")
|
|
if _, err := os.Stat(errsDir); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var (
|
|
out []Violation
|
|
typedErrors = make(map[string]token.Position) // name → first decl position
|
|
predicateOf = make(map[string]struct{}) // type names with matching IsXxx
|
|
testMentions = make(map[string]struct{})
|
|
)
|
|
|
|
fset := token.NewFileSet()
|
|
entries, err := os.ReadDir(errsDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// First pass: parse every .go in errs/ (no recursion — projection/ is
|
|
// covered separately if/when we extend the rule).
|
|
var testSources []string
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".go") {
|
|
continue
|
|
}
|
|
full := filepath.Join(errsDir, e.Name())
|
|
src, readErr := os.ReadFile(full) //nolint:gosec // operator-provided path.
|
|
if readErr != nil {
|
|
return nil, readErr
|
|
}
|
|
rel, _ := filepath.Rel(root, full)
|
|
rel = filepath.ToSlash(rel)
|
|
file, parseErr := parser.ParseFile(fset, full, src, parser.ParseComments)
|
|
if parseErr != nil {
|
|
continue // parse errors aren't this lint's concern; vet/compile will catch them.
|
|
}
|
|
if strings.HasSuffix(e.Name(), "_test.go") {
|
|
testSources = append(testSources, string(src))
|
|
continue
|
|
}
|
|
|
|
// Per-file CheckProblemEmbed AST check (embeds Problem).
|
|
out = append(out, CheckProblemEmbed(rel, string(src))...)
|
|
|
|
// Collect typed error names and predicate names.
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
switch d := n.(type) {
|
|
case *ast.TypeSpec:
|
|
// Only consider EXPORTED *Error structs — unexported helper
|
|
// types ending in "Error" are not part of the typed
|
|
// taxonomy and would create false-positive missing-
|
|
// predicate violations.
|
|
if _, ok := d.Type.(*ast.StructType); ok && ast.IsExported(d.Name.Name) && strings.HasSuffix(d.Name.Name, "Error") {
|
|
if _, dup := typedErrors[d.Name.Name]; !dup {
|
|
typedErrors[d.Name.Name] = fset.Position(d.Pos())
|
|
}
|
|
}
|
|
case *ast.FuncDecl:
|
|
if d.Recv != nil {
|
|
return true // method, not predicate
|
|
}
|
|
name := d.Name.Name
|
|
if !strings.HasPrefix(name, "Is") {
|
|
return true
|
|
}
|
|
// Predicate convention: IsValidation → ValidationError.
|
|
typeName := name[2:] + "Error"
|
|
predicateOf[typeName] = struct{}{}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
|
|
// Test-file mentions of typed error names.
|
|
for _, src := range testSources {
|
|
for name := range typedErrors {
|
|
if strings.Contains(src, name) {
|
|
testMentions[name] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Walk the typed errors and emit diagnostics for missing predicate / test.
|
|
for name, pos := range typedErrors {
|
|
relFile := pos.Filename
|
|
if r, relErr := filepath.Rel(root, pos.Filename); relErr == nil {
|
|
relFile = filepath.ToSlash(r)
|
|
}
|
|
// Predicate (e.g. ValidationError needs IsValidation).
|
|
if _, ok := predicateOf[name]; !ok {
|
|
out = append(out, Violation{
|
|
Rule: "problem_embed",
|
|
Action: ActionReject,
|
|
File: relFile,
|
|
Line: pos.Line,
|
|
Message: "typed error " + name + " has no matching Is" + strings.TrimSuffix(name, "Error") + " predicate in errs/predicates.go",
|
|
Suggestion: "add `func Is" + strings.TrimSuffix(name, "Error") +
|
|
"(err error) bool { var x *" + name + "; return errors.As(err, &x) }` to errs/predicates.go",
|
|
})
|
|
}
|
|
// Test mention.
|
|
if _, ok := testMentions[name]; !ok {
|
|
out = append(out, Violation{
|
|
Rule: "problem_embed",
|
|
Action: ActionReject,
|
|
File: relFile,
|
|
Line: pos.Line,
|
|
Message: "typed error " + name + " has no test exercising it in errs/*_test.go",
|
|
Suggestion: "add at least one test in errs/ that references " + name + " (smoke construct + predicate assertion is enough)",
|
|
})
|
|
}
|
|
}
|
|
|
|
return out, nil
|
|
}
|