Files
larksuite-cli/lint/errscontract/scan.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

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
}