mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 10:29:48 +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
10 KiB
Go
386 lines
10 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package errscontract
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// fixtureRepo lays out a tiny repo on tmpfs that mimics the live layout enough
|
|
// for ScanRepo / CheckErrsContract to exercise. Each entry is path → content.
|
|
type fixtureRepo map[string]string
|
|
|
|
func writeFixture(t *testing.T, files fixtureRepo) string {
|
|
t.Helper()
|
|
root := t.TempDir()
|
|
for rel, content := range files {
|
|
full := filepath.Join(root, rel)
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
t.Fatalf("mkdir %s: %v", full, err)
|
|
}
|
|
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
|
t.Fatalf("write %s: %v", full, err)
|
|
}
|
|
}
|
|
return root
|
|
}
|
|
|
|
func TestLoadSubtypeAllowlist_ExtractsTypedConstValues(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/subtypes.go": `package errs
|
|
|
|
type Subtype string
|
|
|
|
const (
|
|
SubtypeMissingScope Subtype = "missing_scope"
|
|
SubtypeRateLimit Subtype = "rate_limit"
|
|
)
|
|
|
|
const (
|
|
UnrelatedConst = "ignore_me" // not Subtype-typed
|
|
)
|
|
`,
|
|
})
|
|
got, err := LoadSubtypeAllowlist(filepath.Join(root, "errs", "subtypes.go"))
|
|
if err != nil {
|
|
t.Fatalf("LoadSubtypeAllowlist: %v", err)
|
|
}
|
|
want := map[string]struct{}{"missing_scope": {}, "rate_limit": {}}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("size mismatch: got %d, want %d (%+v)", len(got), len(want), got)
|
|
}
|
|
for k := range want {
|
|
if _, ok := got[k]; !ok {
|
|
t.Errorf("missing %q in allowlist", k)
|
|
}
|
|
}
|
|
if _, ok := got["ignore_me"]; ok {
|
|
t.Errorf("untyped const leaked into allowlist")
|
|
}
|
|
}
|
|
|
|
// TestLoadSubtypeAllowlists_WalksAllSubtypesFiles pins the multi-file load:
|
|
// constants from every errs/subtypes*.go must contribute to both the values
|
|
// allowlist and the declared-names set.
|
|
func TestLoadSubtypeAllowlists_WalksAllSubtypesFiles(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/subtypes.go": `package errs
|
|
|
|
type Subtype string
|
|
|
|
const (
|
|
SubtypeMissingScope Subtype = "missing_scope"
|
|
)
|
|
`,
|
|
"errs/subtypes_service_task.go": `package errs
|
|
|
|
const (
|
|
SubtypeTaskInvalidParams Subtype = "task_invalid_params"
|
|
)
|
|
`,
|
|
})
|
|
values, names, err := LoadSubtypeAllowlists(filepath.Join(root, "errs"))
|
|
if err != nil {
|
|
t.Fatalf("LoadSubtypeAllowlists: %v", err)
|
|
}
|
|
for _, v := range []string{"missing_scope", "task_invalid_params"} {
|
|
if _, ok := values[v]; !ok {
|
|
t.Errorf("values missing %q (across-file load broken)", v)
|
|
}
|
|
}
|
|
for _, n := range []string{"SubtypeMissingScope", "SubtypeTaskInvalidParams"} {
|
|
if _, ok := names[n]; !ok {
|
|
t.Errorf("names missing %q (across-file load broken)", n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckErrsContract_FlagsMissingPredicateAndTest(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
|
|
type Problem struct{}
|
|
|
|
type MissingError struct {
|
|
Problem
|
|
}
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
// IsMissing predicate intentionally absent
|
|
`,
|
|
// No errs/*_test.go file → MissingError lacks test coverage.
|
|
"internal/errclass/codemeta.go": `package errclass
|
|
|
|
type CodeMeta struct{}
|
|
|
|
var codeMeta = map[int]CodeMeta{1234: {}}
|
|
`,
|
|
})
|
|
v, err := CheckErrsContract(root)
|
|
if err != nil {
|
|
t.Fatalf("CheckErrsContract: %v", err)
|
|
}
|
|
var missingPredicate, missingTest int
|
|
for _, vv := range v {
|
|
switch {
|
|
case strings.Contains(vv.Message, "no matching IsMissing predicate"):
|
|
missingPredicate++
|
|
case strings.Contains(vv.Message, "no test exercising it"):
|
|
missingTest++
|
|
}
|
|
// Diagnostics emitted by CheckErrsContract must use repo-relative paths
|
|
// (same convention as walker-side rules), not absolute filesystem paths
|
|
// resolved via parser.ParseFile.
|
|
if strings.Contains(vv.Message, "MissingError") && vv.File != "errs/types.go" {
|
|
t.Errorf("violation File = %q, want repo-relative %q: %+v",
|
|
vv.File, "errs/types.go", vv)
|
|
}
|
|
}
|
|
if missingPredicate != 1 {
|
|
t.Errorf("missing-predicate diagnostics = %d, want 1: %+v", missingPredicate, v)
|
|
}
|
|
if missingTest != 1 {
|
|
t.Errorf("missing-test diagnostics = %d, want 1: %+v", missingTest, v)
|
|
}
|
|
}
|
|
|
|
func TestCheckErrsContract_AcceptsCompleteContract(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
|
|
type Problem struct{}
|
|
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
|
|
import "testing"
|
|
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
"internal/errclass/codemeta.go": `package errclass
|
|
|
|
type CodeMeta struct{}
|
|
|
|
var m = map[int]CodeMeta{42: {}}
|
|
`,
|
|
})
|
|
v, err := CheckErrsContract(root)
|
|
if err != nil {
|
|
t.Fatalf("CheckErrsContract: %v", err)
|
|
}
|
|
if len(v) != 0 {
|
|
t.Errorf("complete contract should pass, got %d violations: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestScanRepo_DetectsServiceRegistrarAndBadSubtype(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
|
|
type Problem struct{}
|
|
|
|
type Subtype string
|
|
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
import "testing"
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
"errs/subtypes.go": `package errs
|
|
|
|
const (
|
|
SubtypeKnown Subtype = "known"
|
|
)
|
|
`,
|
|
"internal/errclass/codemeta.go": `package errclass
|
|
|
|
type CodeMeta struct{}
|
|
|
|
var m = map[int]CodeMeta{1: {}}
|
|
`,
|
|
// Service file with a registrar AND a bad Subtype literal.
|
|
"shortcuts/task/bad.go": `package task
|
|
|
|
func init() {
|
|
mergeCodeMeta(nil, "task")
|
|
}
|
|
|
|
var _ = struct{ Subtype string }{Subtype: "not_known"}
|
|
`,
|
|
// Test files are exempt from C/D/E (rule pre-filter).
|
|
"shortcuts/task/bad_test.go": `package task
|
|
func placeholder() {}
|
|
`,
|
|
})
|
|
v, err := ScanRepo(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanRepo: %v", err)
|
|
}
|
|
var sawRegistrar, sawBadSubtype bool
|
|
for _, vv := range v {
|
|
if vv.Rule == "no_registrar" && strings.Contains(vv.File, "shortcuts/task/bad.go") {
|
|
sawRegistrar = true
|
|
}
|
|
if vv.Rule == "declared_subtype" && strings.Contains(vv.Message, "not_known") {
|
|
sawBadSubtype = true
|
|
}
|
|
}
|
|
if !sawRegistrar {
|
|
t.Errorf("ScanRepo missed CheckNoRegistrar registrar; got %+v", v)
|
|
}
|
|
if !sawBadSubtype {
|
|
t.Errorf("ScanRepo missed CheckDeclaredSubtype undeclared subtype; got %+v", v)
|
|
}
|
|
}
|
|
|
|
// TestScanRepo_EmitsAdvisoryWhenTypedScopeUnavailable pins Refinement 2:
|
|
// when a fixture LOOKS like a Go repo (has a go.mod) but typed loading
|
|
// cannot produce a usable errs.Subtype const set, ScanRepo emits a single
|
|
// ActionWarning advisory so reviewers know CheckDeclaredSubtype ran in a less-strict
|
|
// mode. ActionWarning is print-only — CI exit-code logic does not fail
|
|
// the run on it (proven by the lint main.go exit-code branch).
|
|
func TestScanRepo_EmitsAdvisoryWhenTypedScopeUnavailable(t *testing.T) {
|
|
// Fixture: a Go-looking repo (has go.mod) but errs/ contains a
|
|
// Subtype type with NO declared Subtype consts. LoadTypedScope will
|
|
// initialize but errsSubtypeConsts stays empty → Enabled() returns
|
|
// false under the tightened contract.
|
|
root := writeFixture(t, fixtureRepo{
|
|
"go.mod": "module example.com/fixture\n\ngo 1.23\n",
|
|
"errs/types.go": `package errs
|
|
|
|
type Problem struct{}
|
|
type Subtype string
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
import "testing"
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
// subtypes.go is present so LoadSubtypeAllowlists succeeds, but the
|
|
// const block is empty so no values/names are declared.
|
|
"errs/subtypes.go": `package errs
|
|
|
|
const SubtypeKnown Subtype = "known"
|
|
`,
|
|
})
|
|
v, err := ScanRepo(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanRepo: %v", err)
|
|
}
|
|
|
|
advisoryCount := 0
|
|
for _, vv := range v {
|
|
if vv.Rule == "declared_subtype" && vv.Action == ActionWarning &&
|
|
strings.Contains(vv.Message, "typed resolution unavailable") {
|
|
advisoryCount++
|
|
}
|
|
}
|
|
if advisoryCount != 1 {
|
|
t.Errorf("advisory count = %d, want exactly 1; got violations: %+v", advisoryCount, v)
|
|
}
|
|
// The advisory must NOT escalate to REJECT — ActionWarning is print-only.
|
|
// (We don't assert rejectCount==0 in general since the fixture may emit
|
|
// other rejections; we only assert the advisory itself is a WARNING.)
|
|
for _, vv := range v {
|
|
if vv.Action == ActionReject && strings.Contains(vv.Message, "typed resolution unavailable") {
|
|
t.Errorf("advisory must be ActionWarning, not REJECT (would fail CI): %+v", vv)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestScanRepo_NoAdvisoryWithoutGoMod pins the scoping: fixtures that lack
|
|
// a go.mod (the common unit-test shape) must NOT emit the advisory, since
|
|
// the workspace is not a Go repo from the loader's perspective.
|
|
func TestScanRepo_NoAdvisoryWithoutGoMod(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
type Problem struct{}
|
|
type Subtype string
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
import "testing"
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
"errs/subtypes.go": `package errs
|
|
const SubtypeKnown Subtype = "known"
|
|
`,
|
|
})
|
|
v, err := ScanRepo(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanRepo: %v", err)
|
|
}
|
|
for _, vv := range v {
|
|
if strings.Contains(vv.Message, "typed resolution unavailable") {
|
|
t.Errorf("no go.mod present → advisory must not fire; got %+v", vv)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanRepo_LabelTriggerForAdHocSubtype(t *testing.T) {
|
|
root := writeFixture(t, fixtureRepo{
|
|
"errs/types.go": `package errs
|
|
type Problem struct{}
|
|
type Subtype string
|
|
type FooError struct{ Problem }
|
|
`,
|
|
"errs/predicates.go": `package errs
|
|
func IsFoo(err error) bool { return false }
|
|
`,
|
|
"errs/foo_test.go": `package errs_test
|
|
import "testing"
|
|
func TestFooError(t *testing.T) { _ = FooError{} }
|
|
`,
|
|
"errs/subtypes.go": `package errs
|
|
const (
|
|
SubtypeKnown Subtype = "known"
|
|
)
|
|
`,
|
|
"internal/errclass/codemeta.go": `package errclass
|
|
type CodeMeta struct{}
|
|
var m = map[int]CodeMeta{}
|
|
`,
|
|
"shortcuts/task/maybe.go": `package task
|
|
var _ = struct{ Subtype string }{Subtype: "ad_hoc_quota_breach"}
|
|
`,
|
|
})
|
|
v, err := ScanRepo(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanRepo: %v", err)
|
|
}
|
|
var sawLabel bool
|
|
for _, vv := range v {
|
|
if vv.Action == ActionLabel &&
|
|
strings.Contains(vv.Message, "needs-taxonomy-decision") {
|
|
sawLabel = true
|
|
}
|
|
if vv.Action == ActionReject &&
|
|
strings.Contains(vv.Message, "ad_hoc_quota_breach") {
|
|
t.Errorf("ad_hoc_* must NOT be REJECTED (it's LABEL): %+v", vv)
|
|
}
|
|
}
|
|
if !sawLabel {
|
|
t.Errorf("ScanRepo missed CheckAdHocSubtype label trigger; got %+v", v)
|
|
}
|
|
}
|