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

596 lines
17 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errscontract
import (
"strings"
"testing"
)
// 4 source-level rules:
// (B) typed Error must embed Problem → REJECT
// (C) no service-side mergeCodeMeta / registrar → REJECT
// (D) Subtype: "ad_hoc_*" literal → LABEL (governance signal)
// (E) Subtype value not in declared allowlist → REJECT / LABEL / WARNING
func TestCheckProblemEmbed_RejectsMissingProblemEmbed(t *testing.T) {
src := `package errs
type FrobnicateError struct {
Code int
Msg string
}
`
v := CheckProblemEmbed("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "FrobnicateError") {
t.Errorf("message should name the violating type: %s", v[0].Message)
}
}
func TestCheckProblemEmbed_AcceptsPackageLocalEmbed(t *testing.T) {
src := `package errs
type Problem struct{}
type GoodError struct {
Problem
Extra string
}
`
v := CheckProblemEmbed("errs/types.go", src)
if len(v) != 0 {
t.Errorf("compliant struct should pass, got: %+v", v)
}
}
func TestCheckProblemEmbed_AcceptsImportedEmbed(t *testing.T) {
// `errs.Problem` selector form: used by re-export packages.
src := `package alias
import "github.com/larksuite/cli/errs"
type GoodError struct {
errs.Problem
Extra string
}
`
v := CheckProblemEmbed("internal/alias/x.go", src)
if len(v) != 0 {
t.Errorf("imported-embed should pass, got: %+v", v)
}
}
func TestCheckProblemEmbed_RejectsSecurityPolicyErrorWithoutProblem(t *testing.T) {
// Production SecurityPolicyError embeds Problem (see errs/types.go); the
// previous CheckProblemEmbed whitelist for this type was dead code that would also
// mask a future regression where the embed gets dropped.
src := `package errs
type SecurityPolicyError struct {
ChallengeURL string
}
`
v := CheckProblemEmbed("errs/types.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "SecurityPolicyError") {
t.Errorf("message should name the violating type: %s", v[0].Message)
}
}
func TestCheckProblemEmbed_AcceptsSecurityPolicyErrorWithProblem(t *testing.T) {
// Mirrors the real errs/types.go declaration — must pass with no violation.
src := `package errs
type Problem struct{}
type SecurityPolicyError struct {
Problem
ChallengeURL string
}
`
v := CheckProblemEmbed("errs/types.go", src)
if len(v) != 0 {
t.Errorf("compliant SecurityPolicyError must pass, got: %+v", v)
}
}
func TestCheckNoRegistrar_RejectsMergeCodeMetaInShortcuts(t *testing.T) {
src := `package task
func init() {
mergeCodeMeta(taskMap, "task")
}
var taskMap = map[int]any{}
`
v := CheckNoRegistrar("shortcuts/task/init.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "mergeCodeMeta") {
t.Errorf("message must name the offending call: %s", v[0].Message)
}
if !strings.Contains(v[0].Suggestion, "internal/errclass/codemeta_") {
t.Errorf("suggestion must point to the right location: %s", v[0].Suggestion)
}
}
func TestCheckNoRegistrar_RejectsRegisterServiceMapInInternal(t *testing.T) {
src := `package auth
import "github.com/larksuite/cli/internal/output"
func init() {
output.RegisterServiceMap("auth", nil)
}
`
v := CheckNoRegistrar("internal/auth/init.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "RegisterServiceMap") {
t.Errorf("message must name the offending call: %s", v[0].Message)
}
}
func TestCheckNoRegistrar_AllowsInternalErrclass(t *testing.T) {
// internal/errclass legitimately owns mergeCodeMeta; rule must not fire here.
src := `package errclass
func init() {
mergeCodeMeta(taskCodeMeta, "task")
}
var taskCodeMeta = map[int]any{}
`
v := CheckNoRegistrar("internal/errclass/codemeta_task.go", src)
if len(v) != 0 {
t.Errorf("internal/errclass must be exempt, got: %+v", v)
}
}
func TestCheckNoRegistrar_IgnoresTestFiles(t *testing.T) {
src := `package task_test
func TestFoo(t *testing.T) {
mergeCodeMeta(nil, "fixture")
}
`
v := CheckNoRegistrar("shortcuts/task/init_test.go", src)
if len(v) != 0 {
t.Errorf("test fixtures must be exempt, got: %+v", v)
}
}
func TestCheckNoRegistrar_IgnoresCmdAndRoot(t *testing.T) {
src := `package main
func init() {
mergeCodeMeta(nil, "x")
}
`
v := CheckNoRegistrar("cmd/foo/main.go", src)
if len(v) != 0 {
t.Errorf("cmd/ paths are out of CheckNoRegistrar scope, got: %+v", v)
}
}
func TestCheckAdHocSubtype_EmitsLabel(t *testing.T) {
src := `package task
func makeErr() any {
return struct{ Subtype string }{Subtype: "ad_hoc_task_quota_breach"}
}
`
v := CheckAdHocSubtype("shortcuts/task/quota.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionLabel {
t.Errorf("action = %q, want LABEL (ad_hoc_* is soft governance signal)", v[0].Action)
}
if !strings.Contains(v[0].Message, "needs-taxonomy-decision") {
t.Errorf("message should carry the label prefix so CI can grep it: %s", v[0].Message)
}
if !strings.Contains(v[0].Suggestion, "1 week") {
t.Errorf("suggestion should state the ad_hoc_* promotion window: %s", v[0].Suggestion)
}
}
func TestCheckAdHocSubtype_DetectsCastForm(t *testing.T) {
// Subtype field assigned via errs.Subtype("ad_hoc_xxx") cast.
src := `package task
type problem struct{ Subtype any }
var _ = problem{Subtype: Subtype("ad_hoc_new_feature")}
func Subtype(s string) string { return s }
`
v := CheckAdHocSubtype("shortcuts/task/x.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionLabel {
t.Errorf("cast form must also LABEL, got %q", v[0].Action)
}
}
func TestCheckDeclaredSubtype(t *testing.T) {
allowlist := map[string]struct{}{
"missing_scope": {},
"rate_limit": {},
"invalid_parameters": {},
}
cases := []struct {
name string
src string
wantAction Action
wantInMsg string
}{
{
name: "named_const_selector_accepted",
src: `package x
import "github.com/larksuite/cli/errs"
var _ = struct{ Subtype errs.Subtype }{Subtype: errs.SubtypeMissingScope}
`,
wantAction: "",
},
{
name: "literal_in_allowlist_accepted",
src: `package x
var _ = struct{ Subtype string }{Subtype: "missing_scope"}
`,
wantAction: "",
},
{
name: "undeclared_literal_rejected",
src: `package x
var _ = struct{ Subtype string }{Subtype: "my_custom_thing"}
`,
wantAction: ActionReject,
wantInMsg: "my_custom_thing",
},
{
name: "undeclared_via_cast_rejected",
src: `package x
import "github.com/larksuite/cli/errs"
var _ = struct{ Subtype errs.Subtype }{Subtype: errs.Subtype("custom_value")}
`,
wantAction: ActionReject,
wantInMsg: "custom_value",
},
{
name: "ad_hoc_does_not_fire_in_rule_e",
src: `package x
var _ = struct{ Subtype string }{Subtype: "ad_hoc_thing"}
`,
// CheckDeclaredSubtype hands ad_hoc_* off to CheckAdHocSubtype — returns no E-class violation.
wantAction: "",
},
{
name: "dynamic_local_var_warns",
src: `package x
var loc = "x"
var _ = struct{ Subtype string }{Subtype: loc}
`,
wantAction: ActionWarning,
wantInMsg: "manual review",
},
{
name: "dynamic_cast_warns",
src: `package x
import "github.com/larksuite/cli/errs"
func f(raw string) { _ = struct{ Subtype errs.Subtype }{Subtype: errs.Subtype(raw)} }
`,
wantAction: ActionWarning,
wantInMsg: "non-literal",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
v := CheckDeclaredSubtype("x.go", tc.src, allowlist)
if tc.wantAction == "" {
if len(v) != 0 {
t.Fatalf("expected pass, got %d violations: %+v", len(v), v)
}
return
}
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != tc.wantAction {
t.Errorf("action = %q, want %q", v[0].Action, tc.wantAction)
}
if tc.wantInMsg != "" && !strings.Contains(v[0].Message, tc.wantInMsg) {
t.Errorf("message %q lacks expected substring %q", v[0].Message, tc.wantInMsg)
}
})
}
}
// TestCheckDeclaredSubtype_DetectsPositionalCodeMetaLiteral pins that codemeta_task.go and
// codemeta.go use positional `{cat, subtype, retryable}` literals inside a
// `map[int]CodeMeta{...}` — element [1] is the Subtype slot. The AST walker
// must recognise the positional form; otherwise an undeclared subtype cast
// here would bypass CheckDeclaredSubtype.
func TestCheckDeclaredSubtype_DetectsPositionalCodeMetaLiteral(t *testing.T) {
allowlist := map[string]struct{}{
"missing_scope": {},
}
src := `package output
import "github.com/larksuite/cli/errs"
type CodeMeta struct {
Category errs.Category
Subtype errs.Subtype
Retryable bool
}
var m = map[int]CodeMeta{
1: {errs.CategoryAPI, errs.Subtype("totally_bogus_undeclared"), false},
}
`
v := CheckDeclaredSubtype("internal/output/codemeta_test_fixture.go", src, allowlist)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "totally_bogus_undeclared") {
t.Errorf("message should name the violating subtype: %s", v[0].Message)
}
}
// TestCheckDeclaredSubtype_AcceptsPositionalCodeMetaLiteral: same positional form but the
// Subtype literal is in the allowlist — no violation should fire.
func TestCheckDeclaredSubtype_AcceptsPositionalCodeMetaLiteral(t *testing.T) {
allowlist := map[string]struct{}{
"missing_scope": {},
}
src := `package output
import "github.com/larksuite/cli/errs"
type CodeMeta struct {
Category errs.Category
Subtype errs.Subtype
Retryable bool
}
var m = map[int]CodeMeta{
1: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false},
2: {errs.CategoryAuthorization, errs.Subtype("missing_scope"), false},
}
`
v := CheckDeclaredSubtype("internal/output/codemeta_test_fixture.go", src, allowlist)
if len(v) != 0 {
t.Errorf("allowlisted subtypes in positional form must pass, got: %+v", v)
}
}
// TestCheckDeclaredSubtype_DetectsPositionalCodeMetaLiteralInSlice: covers the slice form
// `[]CodeMeta{{cat, subtype, retryable}}` so other call-site shapes are also
// guarded.
func TestCheckDeclaredSubtype_DetectsPositionalCodeMetaLiteralInSlice(t *testing.T) {
allowlist := map[string]struct{}{
"missing_scope": {},
}
src := `package output
import "github.com/larksuite/cli/errs"
type CodeMeta struct {
Category errs.Category
Subtype errs.Subtype
Retryable bool
}
var s = []CodeMeta{
{errs.CategoryAPI, errs.Subtype("undeclared_via_slice"), false},
}
`
v := CheckDeclaredSubtype("internal/output/codemeta_test_fixture.go", src, allowlist)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "undeclared_via_slice") {
t.Errorf("message should name the violating subtype: %s", v[0].Message)
}
}
// TestCheckDeclaredSubtype_WithNames_RejectsTypoedSelector pins the strengthened CheckDeclaredSubtype:
// when a nameset is supplied, selectors like `errs.SubtypeBogus` that satisfy
// the "Subtype*" prefix but reference no declared constant must REJECT. The
// nil-nameset path preserves the legacy prefix-only acceptance.
func TestCheckDeclaredSubtype_WithNames_RejectsTypoedSelector(t *testing.T) {
allowlist := map[string]struct{}{"missing_scope": {}}
nameset := map[string]struct{}{"SubtypeMissingScope": {}}
// Typo'd selector — REJECT under strengthened rule.
src := `package x
import "github.com/larksuite/cli/errs"
var _ = struct{ Subtype errs.Subtype }{Subtype: errs.SubtypeBogus}
`
v := CheckDeclaredSubtypeWithNames("x.go", src, allowlist, nameset)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if v[0].Action != ActionReject {
t.Errorf("action = %q, want REJECT", v[0].Action)
}
if !strings.Contains(v[0].Message, "SubtypeBogus") {
t.Errorf("message should name the offending selector: %s", v[0].Message)
}
// Same source, nil nameset → legacy prefix-only path, no violation.
v2 := CheckDeclaredSubtypeWithNames("x.go", src, allowlist, nil)
if len(v2) != 0 {
t.Errorf("nil nameset must preserve legacy prefix acceptance, got: %+v", v2)
}
}
// TestCheckDeclaredSubtype_WithNames_AcceptsDeclaredSelector: declared selector with nameset
// supplied must still pass.
func TestCheckDeclaredSubtype_WithNames_AcceptsDeclaredSelector(t *testing.T) {
allowlist := map[string]struct{}{"missing_scope": {}}
nameset := map[string]struct{}{"SubtypeMissingScope": {}}
src := `package x
import "github.com/larksuite/cli/errs"
var _ = struct{ Subtype errs.Subtype }{Subtype: errs.SubtypeMissingScope}
`
v := CheckDeclaredSubtypeWithNames("x.go", src, allowlist, nameset)
if len(v) != 0 {
t.Errorf("declared selector must pass, got: %+v", v)
}
}
// TestCheckDeclaredSubtype_WithNames_RejectsTypoedIdent: in-package identifier form (no errs.
// prefix) must also be checked against the nameset.
func TestCheckDeclaredSubtype_WithNames_RejectsTypoedIdent(t *testing.T) {
allowlist := map[string]struct{}{"missing_scope": {}}
nameset := map[string]struct{}{"SubtypeMissingScope": {}}
src := `package errs
type Subtype string
type myErr struct{ Subtype Subtype }
var _ = myErr{Subtype: SubtypeNotDeclared}
`
v := CheckDeclaredSubtypeWithNames("internal/x.go", src, allowlist, nameset)
if len(v) != 1 {
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "SubtypeNotDeclared") {
t.Errorf("message should name the offending identifier: %s", v[0].Message)
}
}
func TestCheckDeclaredSubtype_NilAllowlist_IsNoOp(t *testing.T) {
// Caller can disable CheckDeclaredSubtype by passing nil; that should not panic and must
// not emit any E-class violation, even on undeclared subtypes.
src := `package x
var _ = struct{ Subtype string }{Subtype: "anything"}
`
v := CheckDeclaredSubtype("x.go", src, nil)
if len(v) != 0 {
t.Errorf("nil allowlist must disable CheckDeclaredSubtype, got: %+v", v)
}
}
// TestRunAll_OneFileFourViolations exercises the combined entry point: a
// synthetic file under shortcuts/ that violates B, C, D, and E together.
func TestRunAll_OneFileFourViolations(t *testing.T) {
// Path is shortcuts/* so CheckNoRegistrar fires; file declared in errs-like package
// header is irrelevant for B (we test B in errs/ files only via path).
src := `package task
type LooseError struct{}
func init() {
mergeCodeMeta(nil, "task")
}
var _ = struct{ Subtype string }{Subtype: "ad_hoc_thing"}
var _ = struct{ Subtype string }{Subtype: "bogus"}
`
allowlist := map[string]struct{}{
"missing_scope": {},
}
v := RunAll("shortcuts/task/all_bad.go", src, allowlist)
byRule := map[string]int{}
byAction := map[Action]int{}
for _, vv := range v {
byRule[vv.Rule]++
byAction[vv.Action]++
}
// CheckProblemEmbed is path-scoped to errs/, so it does NOT fire on shortcuts/.
if byRule["problem_embed"] != 0 {
t.Errorf("CheckProblemEmbed should not fire outside errs/, got %d", byRule["problem_embed"])
}
if byRule["no_registrar"] != 1 {
t.Errorf("CheckNoRegistrar count = %d, want 1", byRule["no_registrar"])
}
if byRule["adhoc_subtype"] != 1 {
t.Errorf("CheckAdHocSubtype count = %d, want 1", byRule["adhoc_subtype"])
}
if byRule["declared_subtype"] != 1 {
t.Errorf("CheckDeclaredSubtype count = %d, want 1", byRule["declared_subtype"])
}
if byAction[ActionReject] != 2 {
t.Errorf("REJECT count = %d, want 2 (Rules C+E)", byAction[ActionReject])
}
if byAction[ActionLabel] != 1 {
t.Errorf("LABEL count = %d, want 1 (CheckAdHocSubtype)", byAction[ActionLabel])
}
}
func TestRunAll_ErrsPathRunsRuleB(t *testing.T) {
src := `package errs
type NoEmbedError struct {
Code int
}
`
v := RunAll("errs/types.go", src, nil)
if len(v) != 1 || v[0].Rule != "problem_embed" {
t.Fatalf("expected one CheckProblemEmbed violation, got %+v", v)
}
}
// TestCheckProblemEmbed_SkipsUnexportedErrorType pins that CheckProblemEmbed only
// enforces the Problem embed on EXPORTED *Error types — unexported helper
// types that happen to end in "Error" are not part of the public taxonomy
// and would create false-positive REJECT violations.
func TestCheckProblemEmbed_SkipsUnexportedErrorType(t *testing.T) {
src := `package internal
type myInternalError struct {
Code int
Msg string
}
`
v := CheckProblemEmbed("internal/foo/internal.go", src)
if len(v) != 0 {
t.Errorf("expected 0 violations for unexported helper, got %d: %+v", len(v), v)
}
}
// TestCheckNoRegistrar_CatchesMiddleAffix pins that the registrar matcher
// catches RegisterServiceMap even when it has affixes on both sides — the
// older prefix-or-suffix-only check would have missed FooRegisterServiceMapBar.
func TestCheckNoRegistrar_CatchesMiddleAffix(t *testing.T) {
src := `package auth
func init() {
FooRegisterServiceMapBar("auth", nil)
}
func FooRegisterServiceMapBar(name string, _ interface{}) {}
`
v := CheckNoRegistrar("internal/auth/init.go", src)
if len(v) != 1 {
t.Fatalf("expected 1 violation for middle-affix registrar, got %d: %+v", len(v), v)
}
if !strings.Contains(v[0].Message, "FooRegisterServiceMapBar") {
t.Errorf("message must name the offending call: %s", v[0].Message)
}
}