mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +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.
596 lines
17 KiB
Go
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)
|
|
}
|
|
}
|