mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* refactor: retire legacy error envelopes and enforce typed contract
Consolidate all command error reporting onto the typed errs.* contract, remove
the legacy error surface that predated it, and tighten the lint guards so the
contract holds across the whole repository going forward.
Every failure now reaches stderr as one envelope shape: a category, an
optional subtype, a human- and agent-readable message, and a recovery hint,
with invalid parameters listed under `params`. The legacy ExitError envelope,
its constructors, and the boundary bridge that promoted untyped config and
authorization errors are deleted, leaving a single path from error to wire.
Predicate commands keep their silent-exit behavior through a dedicated signal
that carries only an exit code.
Infrastructure paths that still emitted ad-hoc envelopes — flag parsing,
unknown commands and subcommands, plugin and policy guards, confirmation
prompts, and auth/config failures — now classify into the same taxonomy.
Business, API, auth, and config exit codes are preserved; the one behavioral
change is that Cobra usage failures (missing required flag, unknown command,
bad arguments) now emit the typed validation envelope and exit 2, matching the
explicit flag and subcommand guards, instead of Cobra's plain-text exit 1.
Enforcement is repo-wide rather than per-path:
- The errscontract guards run by default everywhere instead of through a
migration allowlist, so legacy envelopes cannot be reintroduced anywhere.
- errorlint runs across the whole repository: every error wrap must use %w and
every comparison must use errors.Is/errors.As, so interior wraps stay legal
but can no longer break the chain the typed boundary relies on.
- The errs-no-bare-wrap guard is keyed by structural prefix instead of an
explicit per-domain allowlist, so new shortcut domains are covered without
editing a list. It runs where forbidigo is enabled (the shortcut domains and
the auth/config/service command groups); repo-wide chain integrity for the
remaining command paths is carried by errorlint above.
* test: align cli_e2e success assertions to the ok envelope
The api and service success path now emits the {"ok":true} envelope, so the
cli_e2e workflow assertions that still expected the old {"code":0} shape via
AssertStdoutStatus(t, 0) fail once they run with live credentials. Switch those
workflow assertions to AssertStdoutStatus(t, true); the fake-payload helper test
in core_test.go keeps its code-shape assertion.
1286 lines
37 KiB
Go
1286 lines
37 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)
|
|
}
|
|
}
|
|
|
|
// (F) direct legacy output.ExitError / output.ErrDetail literals on migrated
|
|
// paths → REJECT; output.ErrBare(...) calls and non-migrated paths pass.
|
|
|
|
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnDrivePath(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return &output.ExitError{Code: 1}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.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, "ExitError") {
|
|
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) {
|
|
for _, path := range []string{
|
|
"shortcuts/markdown/markdown_fetch.go",
|
|
"shortcuts/okr/okr_image_upload.go",
|
|
"shortcuts/task/task_update.go",
|
|
"shortcuts/whiteboard/whiteboard_update.go",
|
|
} {
|
|
t.Run(path, func(t *testing.T) {
|
|
src := `package migrated
|
|
|
|
import "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return &output.ExitError{Code: 1}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral(path, 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, "ExitError") {
|
|
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyEnvelopeLiteral_RejectsErrDetailLiteralOnDrivePath(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() *output.ErrDetail {
|
|
return &output.ErrDetail{Code: 7}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_common.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
|
}
|
|
if !strings.Contains(v[0].Message, "ErrDetail") {
|
|
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyEnvelopeLiteral_AllowsErrBareCallOnDrivePath(t *testing.T) {
|
|
// output.ErrBare(...) is a CallExpr, not a CompositeLit — must NOT fire.
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return output.ErrBare(output.ExitAPI)
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("ErrBare call should pass, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyEnvelopeLiteral_FiresOnAnyPath(t *testing.T) {
|
|
// The guard is now repo-wide: any .go path that re-introduces the legacy
|
|
// literal is flagged, regardless of domain.
|
|
for _, path := range []string{
|
|
"shortcuts/im/im_send.go",
|
|
"shortcuts/some_new_domain/foo.go",
|
|
"internal/auth/login.go",
|
|
"cmd/config/bind.go",
|
|
} {
|
|
t.Run(path, func(t *testing.T) {
|
|
src := `package other
|
|
|
|
import "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return &output.ExitError{Code: 1}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral(path, src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation on %s, got %d: %+v", path, len(v), v)
|
|
}
|
|
if v[0].Action != ActionReject {
|
|
t.Errorf("action = %q, want REJECT", v[0].Action)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyEnvelopeLiteral_SkipsTestFiles(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return &output.ExitError{Code: 1}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export_test.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("_test.go file should be skipped, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
// TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport pins that an aliased
|
|
// import of internal/output cannot bypass the rule: the qualifier is resolved
|
|
// from the import declaration, not matched against the literal string "output".
|
|
func TestCheckNoLegacyEnvelopeLiteral_RejectsAliasedImport(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import legacy "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return &legacy.ExitError{Code: 1}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation for aliased import, 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, "ExitError") {
|
|
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
|
}
|
|
}
|
|
|
|
// TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected guards against a
|
|
// regression where resolving by import path accidentally drops the default
|
|
// (non-aliased) `output` case.
|
|
func TestCheckNoLegacyEnvelopeLiteral_NormalImportStillRejected(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return &output.ExitError{Code: 1}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation for default import, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
// TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed: output.ErrBare is
|
|
// a CallExpr, not a composite literal — even under an alias it must not fire.
|
|
func TestCheckNoLegacyEnvelopeLiteral_ErrBareAliasedStillAllowed(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import legacy "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return legacy.ErrBare(legacy.ExitAPI)
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("ErrBare call should pass, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
// TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport: a dot-import surfaces
|
|
// ExitError / ErrDetail as bare unqualified idents; the rule must still catch
|
|
// the composite literal.
|
|
func TestCheckNoLegacyEnvelopeLiteral_RejectsDotImport(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import . "github.com/larksuite/cli/internal/output"
|
|
|
|
func boom() error {
|
|
return &ExitError{Code: 1}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation for dot-import, got %d: %+v", len(v), v)
|
|
}
|
|
if !strings.Contains(v[0].Message, "ExitError") {
|
|
t.Errorf("message should name the legacy type: %s", v[0].Message)
|
|
}
|
|
}
|
|
|
|
// TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses: a same-named
|
|
// selector on an unrelated package (not the legacy output import path) must not
|
|
// trigger a false positive.
|
|
func TestCheckNoLegacyEnvelopeLiteral_UnrelatedSelectorPasses(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "example.com/other/output"
|
|
|
|
func boom() error {
|
|
return &output.ExitError{Code: 1}
|
|
}
|
|
`
|
|
v := CheckNoLegacyEnvelopeLiteral("shortcuts/drive/drive_export.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("unrelated package selector must not fire, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnDrivePath(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom(runtime *common.RuntimeContext) error {
|
|
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.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, "CallAPI") {
|
|
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_RejectsCallAPIOnTaskPath(t *testing.T) {
|
|
src := `package task
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom(runtime *common.RuntimeContext) error {
|
|
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/task/task_update.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, "CallAPI") {
|
|
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_RejectsDoAPIJSONWithLogIDOnDrivePath(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom(runtime *common.RuntimeContext) error {
|
|
_, err := runtime.DoAPIJSONWithLogID("POST", "/x", nil, nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_export.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation, got %d: %+v", len(v), v)
|
|
}
|
|
if !strings.Contains(v[0].Message, "DoAPIJSONWithLogID") {
|
|
t.Errorf("message should name the legacy method: %s", v[0].Message)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_AllowsTypedWrapperCall(t *testing.T) {
|
|
// driveCallAPI is an unqualified call (*ast.Ident), not a selector — must NOT fire.
|
|
src := `package drive
|
|
|
|
func boom(runtime *common.RuntimeContext) error {
|
|
_, err := driveCallAPI(runtime, "POST", "/x", nil, nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("typed wrapper call must not fire, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_AllowsRawAPIAndDoAPI(t *testing.T) {
|
|
// RawAPI / DoAPI return the raw response for the caller to classify and do
|
|
// not emit a legacy envelope — they are not banned.
|
|
src := `package drive
|
|
|
|
func boom(runtime *common.RuntimeContext) error {
|
|
_, _ = runtime.RawAPI("POST", "/x", nil, nil)
|
|
_, err := runtime.DoAPI(nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_api.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("RawAPI / DoAPI must not fire, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_FiresOnAnyCommonImportingPath(t *testing.T) {
|
|
// The guard is now repo-wide: any path importing shortcuts/common that
|
|
// re-introduces a legacy runtime call is flagged, regardless of domain.
|
|
for _, path := range []string{
|
|
"shortcuts/im/im_send.go",
|
|
"shortcuts/some_new_domain/sample.go",
|
|
"internal/cmdutil/helper.go",
|
|
} {
|
|
t.Run(path, func(t *testing.T) {
|
|
src := `package contact
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom(runtime *common.RuntimeContext) error {
|
|
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall(path, src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation on %s, got %d: %+v", path, len(v), v)
|
|
}
|
|
if v[0].Action != ActionReject {
|
|
t.Errorf("action = %q, want REJECT", v[0].Action)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_SkipsFilesWithoutCommonImport(t *testing.T) {
|
|
// The import gate stays: without a shortcuts/common import, a same-named
|
|
// CallAPI method on another receiver is not the legacy RuntimeContext helper.
|
|
src := `package contact
|
|
|
|
func boom(runtime *common.RuntimeContext) error {
|
|
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/some_new_domain/sample.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("file without shortcuts/common import must not fire, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_SkipsTestFiles(t *testing.T) {
|
|
src := `package drive
|
|
|
|
func boom(runtime *common.RuntimeContext) error {
|
|
_, err := runtime.CallAPI("POST", "/x", nil, nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall("shortcuts/drive/drive_create_folder_test.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("test files must be skipped, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *testing.T) {
|
|
helpers := []string{
|
|
"FlagErrorf",
|
|
"MutuallyExclusive",
|
|
"AtLeastOne",
|
|
"ExactlyOne",
|
|
"ValidatePageSize",
|
|
"ValidateChatID",
|
|
"ValidateUserID",
|
|
"ValidateSafePath",
|
|
"RejectDangerousChars",
|
|
"WrapInputStatError",
|
|
"WrapSaveErrorByCategory",
|
|
"ResolveOpenIDs",
|
|
"HandleApiResult",
|
|
}
|
|
paths := []string{
|
|
"shortcuts/doc/docs_fetch_v2.go",
|
|
"shortcuts/drive/drive_search.go",
|
|
"shortcuts/im/im_messages_send.go",
|
|
"shortcuts/mail/mail_send.go",
|
|
"shortcuts/markdown/markdown_fetch.go",
|
|
"shortcuts/okr/okr_progress_create.go",
|
|
"shortcuts/sheets/helpers.go",
|
|
"shortcuts/slides/slides_create.go",
|
|
"shortcuts/task/task_update.go",
|
|
"shortcuts/whiteboard/whiteboard_query.go",
|
|
"shortcuts/wiki/wiki_node_get.go",
|
|
}
|
|
for _, path := range paths {
|
|
for _, helper := range helpers {
|
|
t.Run(path+"_"+helper, func(t *testing.T) {
|
|
src := `package migrated
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
common.` + helper + `()
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall(path, src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation for %s on %s, got %d: %+v", helper, path, len(v), v)
|
|
}
|
|
if v[0].Action != ActionReject {
|
|
t.Errorf("action = %q, want REJECT", v[0].Action)
|
|
}
|
|
if !strings.Contains(v[0].Message, "common."+helper) {
|
|
t.Errorf("message should name helper %s: %s", helper, v[0].Message)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) {
|
|
src := `package calendar
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
common.RejectDangerousChars("--summary", "x")
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/calendar/calendar_create.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].Suggestion, "common.RejectDangerousCharsTyped") {
|
|
t.Errorf("suggestion should name typed replacement, got: %s", v[0].Suggestion)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) {
|
|
src := `package migrated
|
|
|
|
import c "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
f := c.FlagErrorf
|
|
_ = f
|
|
c.WrapInputStatError(nil)
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src)
|
|
if len(v) != 2 {
|
|
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) {
|
|
src := `package migrated
|
|
|
|
import c "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
f := c.FlagErrorf
|
|
_ = f
|
|
c.WrapInputStatError(nil)
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src)
|
|
if len(v) != 2 {
|
|
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) {
|
|
src := `package migrated
|
|
|
|
import c "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
f := c.FlagErrorf
|
|
_ = f
|
|
c.WrapInputStatError(nil)
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src)
|
|
if len(v) != 2 {
|
|
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) {
|
|
src := `package migrated
|
|
|
|
import c "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
f := c.FlagErrorf
|
|
_ = f
|
|
c.WrapInputStatError(nil)
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src)
|
|
if len(v) != 2 {
|
|
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) {
|
|
src := `package migrated
|
|
|
|
import c "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
f := c.FlagErrorf
|
|
_ = f
|
|
c.WrapInputStatError(nil)
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src)
|
|
if len(v) != 2 {
|
|
t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_FiresOnAnyPath(t *testing.T) {
|
|
// The guard is now repo-wide: re-introducing a legacy common helper is
|
|
// flagged regardless of domain.
|
|
for _, path := range []string{
|
|
"shortcuts/im/im_send.go",
|
|
"shortcuts/some_new_domain/sample.go",
|
|
"internal/cmdutil/helper.go",
|
|
} {
|
|
t.Run(path, func(t *testing.T) {
|
|
src := `package contact
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
common.FlagErrorf("relapse")
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall(path, src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation on %s, got %d: %+v", path, len(v), v)
|
|
}
|
|
if v[0].Action != ActionReject {
|
|
t.Errorf("action = %q, want REJECT", v[0].Action)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_RejectsReintroducedUploadAndCallAPIHelpers(t *testing.T) {
|
|
// The three relapse-guard entries added when the legacy bodies were deleted:
|
|
// re-introducing a same-named helper must be rejected with a typed pointer.
|
|
cases := []struct {
|
|
helper string
|
|
wantInSugg string
|
|
}{
|
|
{"UploadDriveMediaAll", "common.UploadDriveMediaAllTyped"},
|
|
{"UploadDriveMediaMultipart", "common.UploadDriveMediaMultipartTyped"},
|
|
{"CallAPI", "runtime.CallAPITyped"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.helper, func(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
common.` + tc.helper + `()
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_upload.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation for %s, got %d: %+v", tc.helper, len(v), v)
|
|
}
|
|
if !strings.Contains(v[0].Suggestion, tc.wantInSugg) {
|
|
t.Errorf("suggestion should name typed replacement %q, got: %s", tc.wantInSugg, v[0].Suggestion)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_AllowsTypedHelpersOnMigratedPath(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
common.ValidationErrorf("typed")
|
|
common.MutuallyExclusiveTyped(nil, "a", "b")
|
|
common.ValidateChatIDTyped("--chat-ids", "oc_abc")
|
|
common.ResolveOpenIDsTyped("--user-ids", nil, nil)
|
|
common.WrapSaveErrorTyped(nil)
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("typed helpers must pass, got: %+v", v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_RejectsAliasedImport(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import c "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
c.FlagErrorf("legacy")
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation for aliased common import, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_RejectsDotImport(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import . "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() {
|
|
FlagErrorf("legacy")
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation for dot-imported common, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyCommonHelperCall_RejectsFunctionValueReference(t *testing.T) {
|
|
src := `package drive
|
|
|
|
import "github.com/larksuite/cli/shortcuts/common"
|
|
|
|
func boom() error {
|
|
f := common.FlagErrorf
|
|
return f("legacy")
|
|
}
|
|
`
|
|
v := CheckNoLegacyCommonHelperCall("shortcuts/drive/drive_search.go", src)
|
|
if len(v) != 1 {
|
|
t.Fatalf("expected 1 violation for function-value reference, got %d: %+v", len(v), v)
|
|
}
|
|
}
|
|
|
|
func TestCheckNoLegacyRuntimeAPICall_SkipsNonCommonReceiver(t *testing.T) {
|
|
// The event domain's APIClient interface has a same-named CallAPI method
|
|
// whose implementation classifies into typed errs.* errors; without the
|
|
// shortcuts/common import the call cannot be the legacy RuntimeContext
|
|
// helper and must not fire.
|
|
src := `package vc
|
|
|
|
import "github.com/larksuite/cli/internal/event"
|
|
|
|
func boom(rt event.APIClient) error {
|
|
_, err := rt.CallAPI(nil, "POST", "/x", nil)
|
|
return err
|
|
}
|
|
`
|
|
v := CheckNoLegacyRuntimeAPICall("events/vc/preconsume.go", src)
|
|
if len(v) != 0 {
|
|
t.Errorf("non-common CallAPI receiver must not fire, got: %+v", v)
|
|
}
|
|
}
|